Merge branch 'feat/blobbi-1124-interactions' into 'main'

Add Blobbi social interactions (kind 1124)

Closes #265

See merge request soapbox-pub/ditto!211
This commit is contained in:
Chad Curtis
2026-05-05 19:11:09 +00:00
39 changed files with 4110 additions and 204 deletions
+61
View File
@@ -23,6 +23,7 @@ These event kinds were created by community contributors and are supported by Di
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
| 1124 | Blobbi Social Interaction | Immutable interaction log for Blobbi social interactions | See [Blobbi Social Interaction](#kind-1124-blobbi-social-interaction) below |
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
@@ -492,6 +493,66 @@ The `content` of kind 11125 is a JSON object. Ditto extends it with a `missions`
Each `Mission` is either a **TallyMission** (`{ id, target, count }`) or an **EventMission** (`{ id, target, events: string[] }`) where `events` contains Nostr event IDs that satisfy the mission. Evolution missions are populated when incubation or evolution begins and cleared when the stage transition completes or is cancelled.
#### Kind 1124: Blobbi Social Interaction
Immutable, regular (non-replaceable) event that logs a single interaction with a Blobbi. These events form an append-only interaction log. They do **not** directly mutate the canonical kind 31124 state — the owner's client consolidates pending interactions into canonical stats via a checkpoint-based system.
**Event structure:**
```json
{
"kind": 1124,
"content": "",
"tags": [
["a", "31124:<owner-pubkey>:<blobbi-d-tag>"],
["p", "<owner-pubkey>"],
["action", "feed"],
["source", "blobbi-page"],
["blobbi", "<short-id>"],
["item", "<item-id>"],
["alt", "Blobbi interaction: feed"]
]
}
```
**Content:** Empty string (`""`).
**Required tags:**
| Tag | Description |
|----------|---------------------------------------------------------------------------------|
| `a` | Coordinate of the target Blobbi: `31124:<owner-pubkey>:<blobbi-d-tag>` |
| `p` | Owner pubkey of the target Blobbi |
| `action` | Interaction action. V1 values: `feed`, `play`, `clean`, `medicate` |
| `source` | UI surface that originated the interaction (e.g. `blobbi-page`, `companion`) |
**Optional tags:**
| Tag | Description |
|----------|--------------------------------------------------------------------|
| `blobbi` | Short Blobbi identifier (10-hex petId extracted from canonical d-tag) |
| `item` | Shop item ID used in the interaction, when applicable |
| `client` | Client identifier (added automatically by the publishing hook) |
**V1 action values:**
| Action | Description |
|------------|------------------------------------------|
| `feed` | Feeding the Blobbi |
| `play` | Playing with the Blobbi (includes music and singing) |
| `clean` | Cleaning the Blobbi |
| `medicate` | Administering medicine to the Blobbi |
The `pet` action is reserved for a future version.
**Processing model:**
- Events are processed in ascending `created_at` order with event `id` (hex string comparison) as tie-breaker
- Cooldown, dedup, and clamping logic live in the projection layer, not at publish time
- If no social checkpoint exists in the Blobbi's kind 31124 content, clients MUST assume no prior consolidation and fetch all 1124 events without a `since` filter
- Owner consolidation writes processed stats back to kind 31124 and advances the checkpoint (stored in the event's `content` JSON). This happens automatically when the owner opens the dashboard.
- After consolidation, kind 1124 events remain available as history but MUST NOT be re-applied to canonical stats. The checkpoint's `last_event_id` and `processed_until` fields delineate the boundary.
---
## Music Tracks & Playlists
@@ -1,6 +1,6 @@
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -23,6 +23,7 @@ import type { DailyMissionAction } from '../lib/daily-missions';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { INTERNAL_TO_INTERACTION_ACTION, emitInteractionEvent } from '@/blobbi/core/lib/blobbi-interaction';
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
@@ -67,6 +68,8 @@ export interface UseBlobbiDirectActionParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** UI surface originating the interaction (used for kind 1124 source tag). Defaults to 'blobbi-page'. */
interactionSource?: string;
}
/**
@@ -86,9 +89,11 @@ export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
interactionSource = 'blobbi-page',
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
@@ -191,6 +196,29 @@ export function useBlobbiDirectAction({
updateCompanionEvent(blobbiEvent);
// ─── Emit kind 1124 interaction event (best-effort, fire-and-forget) ───
// ownerPubkey comes from the target Blobbi event, not the logged-in user,
// so the tags remain correct if this path is later reused for non-owner interactions.
const interactionAction = INTERNAL_TO_INTERACTION_ACTION[action];
if (interactionAction) {
emitInteractionEvent(publishEvent, {
ownerPubkey: canonical.companion.event.pubkey,
blobbiDTag: canonical.companion.d,
action: interactionAction,
source: interactionSource,
});
// Invalidate interactions query so the social projection picks up
// the new 1124 event. The 1124 publish is fire-and-forget, so the
// relay may not have it yet — but the 31124 was already updated
// above, so the owner's UI is already correct via canonical state.
// This invalidation ensures eventual consistency for the projection.
const coordinate = `31124:${canonical.companion.event.pubkey}:${canonical.companion.d}`;
queryClient.invalidateQueries({
queryKey: ['blobbi-interactions', coordinate],
});
}
return {
action,
happinessChange: happinessDelta,
@@ -1,6 +1,6 @@
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -28,6 +28,10 @@ import { trackEvolutionMissionTally, readEvolutionFromStorage, trackInventoryDai
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { INTERNAL_TO_INTERACTION_ACTION, emitInteractionEvent } from '@/blobbi/core/lib/blobbi-interaction';
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Request payload for using an item on a Blobbi companion
@@ -69,11 +73,10 @@ export interface UseBlobbiUseInventoryItemParams {
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
/** UI surface originating the interaction (used for kind 1124 source tag). Defaults to 'blobbi-page'. */
interactionSource?: string;
}
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an item on a Blobbi companion.
*
@@ -92,9 +95,11 @@ export function useBlobbiUseInventoryItem({
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent: _updateProfileEvent,
interactionSource = 'blobbi-page',
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
@@ -281,10 +286,29 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// ─── Emit kind 1124 interaction event (best-effort, fire-and-forget) ───
// ownerPubkey comes from the target Blobbi event, not the logged-in user,
// so the tags remain correct if this path is later reused for non-owner interactions.
const interactionAction = INTERNAL_TO_INTERACTION_ACTION[action];
if (interactionAction) {
emitInteractionEvent(publishEvent, {
ownerPubkey: canonical.companion.event.pubkey,
blobbiDTag: canonical.companion.d,
action: interactionAction,
source: interactionSource,
itemId,
});
}
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
// The 31124 canonical state is already updated above. Invalidate the
// interactions query so the social projection picks up the new 1124.
{
const coordinate = `31124:${canonical.companion.event.pubkey}:${canonical.companion.d}`;
queryClient.invalidateQueries({
queryKey: ['blobbi-interactions', coordinate],
});
}
return {
itemName: shopItem.name,
@@ -63,8 +63,10 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
// Get the BlobbiCompanion from the collection
const blobbi = currentCompanionD ? companionsByD[currentCompanionD] ?? null : null;
// Apply projected decay for accurate visual reactions
// This recalculates every 60 seconds while mounted
// Apply projected decay for accurate visual reactions.
// Owner surfaces use decay-only — social effects are incorporated via
// explicit consolidation, not pre-applied projection.
// This recalculates every 60 seconds while mounted.
const projectedState = useProjectedBlobbiState(blobbi);
// Transform to CompanionData with projected stats
@@ -51,6 +51,7 @@ import {
import { trackEvolutionMissionTally, readEvolutionFromStorage, trackInventoryDailyActions } from '@/blobbi/actions/lib/daily-mission-tracker';
import { serializeEvolutionContent } from '@/blobbi/core/lib/missions';
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { INTERNAL_TO_INTERACTION_ACTION, emitInteractionEvent } from '@/blobbi/core/lib/blobbi-interaction';
import type { UseItemFunction } from './BlobbiActionsContextDef';
@@ -384,10 +385,32 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
updateCompanionInCache(blobbiEvent);
// ─── Emit kind 1124 interaction event (best-effort, fire-and-forget) ───
// ownerPubkey comes from the target Blobbi event, not the logged-in user,
// so the tags remain correct if this path is later reused for non-owner interactions.
const interactionAction = INTERNAL_TO_INTERACTION_ACTION[action];
if (interactionAction && companion) {
emitInteractionEvent(publishEvent, {
ownerPubkey: companion.event.pubkey,
blobbiDTag: companion.d,
action: interactionAction,
source: 'companion',
itemId,
});
}
// ─── Invalidate Queries ───
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
// Invalidate interactions query so social projection reflects the new 1124.
{
const coordinate = `31124:${companion.event.pubkey}:${companion.d}`;
queryClient.invalidateQueries({
queryKey: ['blobbi-interactions', coordinate],
});
}
return { statsChanged };
},
@@ -0,0 +1,91 @@
/**
* Hook for fetching Blobbi interaction history (kind 1124) for the Activity tab.
*
* Unlike `useBlobbiInteractions`, this hook does NOT apply the checkpoint filter.
* It fetches the most recent interactions regardless of whether they have been
* consumed/consolidated. This gives the owner a persistent view of who has been
* caring for their Blobbi.
*
* Read-only: never mutates canonical state.
*/
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { BlobbiCompanion } from '../lib/blobbi';
import {
KIND_BLOBBI_INTERACTION,
parseInteractionEvent,
type BlobbiInteraction,
} from '../lib/blobbi-interaction';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Maximum number of history events to fetch. */
const HISTORY_LIMIT = 50;
// ─── Hook ─────────────────────────────────────────────────────────────────────
export interface UseBlobbiActivityHistoryResult {
/** Parsed interactions sorted newest-first (descending created_at). */
interactions: BlobbiInteraction[];
/** True only while the initial load is in progress with no cached data. */
isLoading: boolean;
}
/**
* Fetch recent interaction history for a Blobbi (no checkpoint filtering).
*
* @param companion - The current Blobbi companion, or null to disable.
*/
export function useBlobbiActivityHistory(
companion: BlobbiCompanion | null,
): UseBlobbiActivityHistoryResult {
const { nostr } = useNostr();
const coordinate = useMemo(() => {
if (!companion) return undefined;
return `31124:${companion.event.pubkey}:${companion.d}`;
}, [companion]);
const query = useQuery({
queryKey: ['blobbi-activity-history', coordinate],
queryFn: async ({ signal }) => {
if (!coordinate || !companion) return [];
const events = await nostr.query(
[{
kinds: [KIND_BLOBBI_INTERACTION],
'#a': [coordinate],
limit: HISTORY_LIMIT,
}],
{ signal },
);
// Validate, parse, exclude owner interactions (same as useBlobbiInteractions).
const ownerPubkey = companion.event.pubkey;
const parsed: BlobbiInteraction[] = [];
for (const event of events) {
if (event.pubkey === ownerPubkey) continue;
const interaction = parseInteractionEvent(event);
if (interaction) parsed.push(interaction);
}
// Sort descending (newest first) for display.
parsed.sort((a, b) => b.createdAt - a.createdAt || b.event.id.localeCompare(a.event.id));
return parsed;
},
enabled: !!coordinate,
staleTime: 2 * 60_000, // 2 minutes
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
});
return {
interactions: query.data ?? [],
isLoading: query.isLoading,
};
}
@@ -0,0 +1,144 @@
/**
* Hook for fetching kind 1124 Blobbi interaction events.
*
* Read-only: does not mutate canonical state, does not consolidate,
* does not apply stat effects. Returns parsed interactions sorted
* deterministically (ascending created_at, id tie-break) for the
* selected Blobbi.
*
* Checkpoint-aware via `resolveSocialCheckpoint()`: if a valid social
* checkpoint exists in the 31124 content, only events after that
* timestamp are fetched. When no valid checkpoint exists (absent,
* malformed, or incomplete), all available events are fetched without
* a `since` filter — up to `BLOBBI_INTERACTIONS_LIMIT` (currently 50).
*
* V1 limitation: the no-checkpoint fallback still applies a finite
* relay-side limit of 50 events. This means only the 50 most-recent
* interactions are fetched, NOT the full history. This is acceptable
* for V1 read-only projection.
*/
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrFilter } from '@nostrify/nostrify';
import type { BlobbiCompanion } from '../lib/blobbi';
import {
KIND_BLOBBI_INTERACTION,
parseInteractionEvent,
sortInteractionEvents,
resolveSocialCheckpoint,
type BlobbiInteraction,
} from '../lib/blobbi-interaction';
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* Maximum number of interaction events to fetch per query.
*
* This limit applies in BOTH the checkpoint and no-checkpoint cases.
* In the no-checkpoint fallback (V1), this means the projection sees
* at most the 50 most-recent events — not the full history.
*/
const BLOBBI_INTERACTIONS_LIMIT = 50;
// ─── Hook ─────────────────────────────────────────────────────────────────────
export interface UseBlobbiInteractionsResult {
/** Parsed interactions in deterministic order (ascending created_at, id tie-break) */
interactions: BlobbiInteraction[];
/** True only while the initial load is in progress with no cached data */
isLoading: boolean;
/** True when the query encountered an error */
isError: boolean;
}
/**
* Fetch and parse kind 1124 interaction events for a Blobbi.
*
* @param companion - The current Blobbi companion, or null when none is selected.
*/
export function useBlobbiInteractions(
companion: BlobbiCompanion | null,
): UseBlobbiInteractionsResult {
const { nostr } = useNostr();
// Derive the `a` coordinate for the target Blobbi.
// Uses the event author (owner pubkey) — not the logged-in user — so the
// coordinate is correct regardless of who is viewing.
const coordinate = useMemo(() => {
if (!companion) return undefined;
return `31124:${companion.event.pubkey}:${companion.d}`;
}, [companion]);
// ── Canonical checkpoint resolution ──
// Uses the single `resolveSocialCheckpoint()` entry point so the query
// layer and projection layer share the exact same checkpoint interpretation.
const resolved = useMemo(
() => resolveSocialCheckpoint(companion),
[companion],
);
const query = useQuery({
queryKey: [
'blobbi-interactions',
coordinate,
resolved.valid ? resolved.checkpoint.processed_until : 0,
resolved.valid ? resolved.checkpoint.last_event_id : '',
],
queryFn: async ({ signal }) => {
if (!coordinate) return [];
const filter: NostrFilter = {
kinds: [KIND_BLOBBI_INTERACTION],
'#a': [coordinate],
limit: BLOBBI_INTERACTIONS_LIMIT,
...(resolved.valid ? { since: resolved.checkpoint.processed_until } : {}),
};
const events = await nostr.query([filter], { signal });
// Validate → parse → sort deterministically (ascending).
// Owner-authored interactions are excluded: when the owner uses an
// item, stat changes are applied directly to 31124. If those 1124
// events were also processed here, the effect would be double-applied
// by the social projection/consolidation pipeline.
const ownerPubkey = companion!.event.pubkey;
const parsed: BlobbiInteraction[] = [];
for (const event of sortInteractionEvents(events)) {
if (event.pubkey === ownerPubkey) continue;
const interaction = parseInteractionEvent(event);
if (interaction) parsed.push(interaction);
}
// ── Canonical boundary handling ──
// This is THE authoritative place where the checkpoint boundary event
// is excluded. Nostr `since` is inclusive, so the last-processed event
// is always re-fetched. We remove it here at the data source so ALL
// downstream consumers (display counts, projection, consolidation)
// receive only genuinely unconsumed interactions.
//
// The dedup sets in applySocialInteractions/consolidateSocialInteractions
// remain as a general safety net for relay-duplicate events (same event
// from multiple relays), NOT for boundary handling.
if (resolved.valid) {
const boundaryId = resolved.checkpoint.last_event_id;
return parsed.filter(ix => ix.event.id !== boundaryId);
}
return parsed;
},
enabled: !!coordinate,
staleTime: 60_000, // 1 minute — interaction log changes slowly
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
});
return {
interactions: query.data ?? [],
isLoading: query.isLoading,
isError: query.isError,
};
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Automatic canonical sync for the owner's selected Blobbi.
*
* When the owner opens /blobbi (or switches selected companion), this hook
* performs a one-shot sync that:
*
* 1. Persists accumulated decay into canonical kind 31124 stats
* 2. Consolidates pending kind 1124 social interactions (if any)
* 3. Advances the social checkpoint accordingly
*
* This replaces the manual "Apply pending care" button. The sync runs at
* most once per companion selection (guarded by a ref keyed on d-tag).
*
* **Sleeping Blobbis are handled correctly.** The pure `applyBlobbiDecay`
* function already applies sleep-regime rates (20% stat decay, energy regen,
* zero base health decay). The sync never changes the `state` tag — no
* auto-wake is performed.
*
* **Publish-loop prevention:** The sync sets a ref after firing and does
* not re-trigger when the companion object updates from its own publish.
* The effect depends only on `companion.d` + interactions-loaded status.
*
* @module useCanonicalSync
*/
import { useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion } from '../lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, statsToTagUpdates } from '../lib/blobbi';
import { applyBlobbiDecay } from '../lib/blobbi-decay';
import { consolidateSocialInteractions } from '../lib/blobbi-social-projection';
import {
resolveSocialCheckpoint,
serializeSocialCheckpoint,
type BlobbiInteraction,
type SocialCheckpoint,
} from '../lib/blobbi-interaction';
import { useNostrPublish } from '@/hooks/useNostrPublish';
// ─── Minimum elapsed time before a decay-only sync is worth publishing ───────
// If decay occurred for less than this many seconds and there are no social
// interactions to consolidate, skip the publish to avoid unnecessary writes.
// 60 seconds: below this, the Math.trunc() rounding in the decay engine
// produces zero deltas for most stats anyway.
const MIN_DECAY_ELAPSED_SECONDS = 60;
// ─── Types ───────────────────────────────────────────────────────────────────
interface UseCanonicalSyncParams {
/**
* The currently selected companion parsed from the owner's 31124 event.
* The hook reads canonical tags/content from `companion.event`.
*/
companion: BlobbiCompanion | null;
/**
* Pending social interactions for this companion (from useBlobbiInteractions).
* Must be sorted ascending by `created_at` with id tie-break.
*/
interactions: readonly BlobbiInteraction[];
/** Whether the interactions query is still loading (initial fetch). */
interactionsLoading: boolean;
/** Cache updater from useBlobbisCollection. */
updateCompanionEvent: (event: NostrEvent) => void;
/**
* The ensureCanonicalBeforeAction helper that returns fresh canonical
* data (auto-migrating legacy pets if needed).
*/
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
} | null>;
/**
* Optional callback fired after social interactions are successfully
* consolidated. Used to trigger visual reward feedback (e.g. hearts).
*/
onSocialConsolidated?: (consumedCount: number) => void;
}
// ─── Hook ────────────────────────────────────────────────────────────────────
/**
* Automatically sync canonical Blobbi state when the owner views /blobbi.
*
* Runs once per companion selection. Waits for interactions to be loaded
* so decay and social consolidation can happen in a single publish.
*/
export function useCanonicalSync({
companion,
interactions,
interactionsLoading,
updateCompanionEvent,
ensureCanonicalBeforeAction,
onSocialConsolidated,
}: UseCanonicalSyncParams): void {
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
// Track which companion d-tag has already been synced this session.
// Resets when the user selects a different companion (different d-tag).
const syncedDRef = useRef<string | null>(null);
// Prevent concurrent runs.
const syncInProgressRef = useRef(false);
// Stable callback that performs the actual sync.
const performSync = useCallback(async (
comp: BlobbiCompanion,
pendingInteractions: readonly BlobbiInteraction[],
) => {
if (syncInProgressRef.current) return;
syncInProgressRef.current = true;
try {
const now = Math.floor(Date.now() / 1000);
// ── Step 1: Apply accumulated decay to canonical stats ──
const decayResult = applyBlobbiDecay({
stage: comp.stage,
state: comp.state,
stats: comp.stats,
lastDecayAt: comp.lastDecayAt,
now,
});
// ── Step 2: Pre-check whether social consolidation would consume anything ──
let hasConsumableInteractions = false;
if (pendingInteractions.length > 0) {
const resolved = resolveSocialCheckpoint(comp);
const result = consolidateSocialInteractions(
decayResult.stats,
pendingInteractions,
resolved.checkpoint,
);
hasConsumableInteractions = result.consumedCount > 0 && !!result.lastConsumed;
}
// ── Step 3: Skip publish if nothing meaningful changed ──
// If no social interactions would be consumed AND elapsed time is too
// short for decay to produce any visible stat change, don't publish.
if (!hasConsumableInteractions && decayResult.elapsedSeconds < MIN_DECAY_ELAPSED_SECONDS) {
return;
}
// ── Step 4: Fetch fresh canonical and publish ──
// We must use ensureCanonicalBeforeAction to get the freshest tags
// (handles migration, multi-device staleness, etc.)
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) return;
// Re-apply decay and consolidation on the truly fresh canonical data.
// This handles the edge case where canonical data changed between the
// initial check and the fresh fetch (e.g. another device published).
const freshNow = Math.floor(Date.now() / 1000);
const freshDecay = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now: freshNow,
});
let publishStats = freshDecay.stats;
let publishContent = canonical.content;
let freshConsumedCount = 0;
if (pendingInteractions.length > 0) {
const freshResolved = resolveSocialCheckpoint(canonical.companion);
const freshResult = consolidateSocialInteractions(
freshDecay.stats,
pendingInteractions,
freshResolved.checkpoint,
);
if (freshResult.consumedCount > 0 && freshResult.lastConsumed) {
publishStats = freshResult.stats;
freshConsumedCount = freshResult.consumedCount;
const freshCheckpoint: SocialCheckpoint = {
processed_until: freshResult.lastConsumed.createdAt,
last_event_id: freshResult.lastConsumed.event.id,
};
publishContent = serializeSocialCheckpoint(canonical.content, freshCheckpoint);
}
}
// Check again whether the fresh data still warrants a publish.
// (Another device may have already consumed the interactions, or the
// fresh event may have a recent last_decay_at.)
if (freshConsumedCount === 0 && freshDecay.elapsedSeconds < MIN_DECAY_ELAPSED_SECONDS) {
return;
}
// ── Step 5: Build tags and publish ──
const newTags = updateBlobbiTags(canonical.allTags, statsToTagUpdates(publishStats, freshNow));
const prev = canonical.companion.event;
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: publishContent,
tags: newTags,
prev,
});
// ── Step 6: Update cache and invalidate interactions ──
updateCompanionEvent(event);
// Invalidate interactions query so it refetches with the new checkpoint
const coordinate = `31124:${comp.event.pubkey}:${comp.d}`;
queryClient.invalidateQueries({
queryKey: ['blobbi-interactions', coordinate],
});
// ── Step 7: Notify caller about social consolidation for visual feedback ──
if (freshConsumedCount > 0 && onSocialConsolidated) {
onSocialConsolidated(freshConsumedCount);
}
} catch (error) {
// Sync is best-effort. If it fails, the user can still interact
// normally (each action persists decay as its first step).
console.error('[useCanonicalSync] Sync failed:', error);
} finally {
syncInProgressRef.current = false;
}
}, [ensureCanonicalBeforeAction, publishEvent, updateCompanionEvent, queryClient, onSocialConsolidated]);
// ── Effect: trigger sync when companion is selected and data is ready ──
useEffect(() => {
if (!companion) return;
if (interactionsLoading) return;
// Already synced this companion
if (syncedDRef.current === companion.d) return;
// Mark as synced immediately to prevent re-triggers from the
// companion object updating after our own publish.
syncedDRef.current = companion.d;
performSync(companion, interactions);
}, [companion, companion?.d, interactions, interactionsLoading, performSync]);
}
@@ -0,0 +1,164 @@
/**
* Owner-side social interaction consolidation hook.
*
* Consumes pending kind 1124 interactions and incorporates their stat effects
* into the canonical kind 31124 state. After successful consolidation:
* - Canonical stats include the consumed social effects
* - The social checkpoint advances past the consumed interactions
* - The `blobbi-interactions` query is invalidated (checkpoint change
* shifts the query key, so subsequent fetches return only new events)
*
* This is the write counterpart to the read-only `applySocialInteractions`.
* It uses `consolidateSocialInteractions` which applies the **exact same**
* rules (dedup, item resolution, stat clamping, event ordering) to ensure
* consolidation and projection are always consistent.
*
* Owner-only: the hook requires the logged-in user to own the companion.
*
* @module useConsolidateSocialInteractions
*/
import { useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { BlobbiCompanion } from '../lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, statsToTagUpdates } from '../lib/blobbi';
import { applyBlobbiDecay } from '../lib/blobbi-decay';
import { consolidateSocialInteractions } from '../lib/blobbi-social-projection';
import {
resolveSocialCheckpoint,
serializeSocialCheckpoint,
type BlobbiInteraction,
type SocialCheckpoint,
} from '../lib/blobbi-interaction';
import { useNostrPublish } from '@/hooks/useNostrPublish';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ConsolidateParams {
/**
* The canonical Blobbi companion (fresh from `ensureCanonicalBeforeAction`).
*/
companion: BlobbiCompanion;
/**
* Fresh content string from the canonical event (preserves evolution, etc.).
*/
content: string;
/**
* All canonical tags (from `ensureCanonicalBeforeAction`).
*/
allTags: string[][];
/**
* Pending interactions to consume — must be sorted ascending by
* `created_at` + id tie-break (as returned by `useBlobbiInteractions`).
*/
interactions: readonly BlobbiInteraction[];
}
interface ConsolidateResult {
/** Number of interactions actually consumed */
consumedCount: number;
}
interface UseConsolidateSocialInteractionsReturn {
/** Trigger consolidation. Returns consumed count, or `null` if nothing was consumed. */
consolidate: (params: ConsolidateParams) => Promise<ConsolidateResult | null>;
/** Whether a consolidation is currently in progress */
isPending: boolean;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook that provides a consolidation function for the owner to consume
* pending social interactions into canonical 31124 state.
*
* @param updateCompanionEvent - Cache updater from `useBlobbisCollection`
*/
export function useConsolidateSocialInteractions(
updateCompanionEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
): UseConsolidateSocialInteractionsReturn {
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const [isPending, setIsPending] = useState(false);
const consolidate = useCallback(async (
params: ConsolidateParams,
): Promise<ConsolidateResult | null> => {
const { companion, content, allTags, interactions } = params;
if (interactions.length === 0) return null;
setIsPending(true);
try {
const now = Math.floor(Date.now() / 1000);
// ── Step 1: Apply accumulated decay to canonical stats ──
const decayResult = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now,
});
// ── Step 2: Resolve the current checkpoint ──
const resolved = resolveSocialCheckpoint(companion);
// ── Step 3: Consolidate interactions onto decayed stats ──
// Uses the exact same rules as projection: same dedup, same item
// resolution, same effect application, same clamping.
const result = consolidateSocialInteractions(
decayResult.stats,
interactions,
resolved.checkpoint,
);
// If no interactions were actually consumed (all were dupes from
// checkpoint boundary), do NOT publish a new 31124.
if (result.consumedCount === 0 || !result.lastConsumed) {
return null;
}
// ── Step 4: Build the new checkpoint ──
const newCheckpoint: SocialCheckpoint = {
processed_until: result.lastConsumed.createdAt,
last_event_id: result.lastConsumed.event.id,
};
// ── Step 5: Serialize the checkpoint into content ──
const newContent = serializeSocialCheckpoint(content, newCheckpoint);
// ── Step 6: Build updated tags with consolidated stats ──
const newTags = updateBlobbiTags(allTags, statsToTagUpdates(result.stats, now));
// ── Step 7: Publish the new 31124 ──
const prev = companion.event;
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
prev,
});
// ── Step 8: Update local cache ──
updateCompanionEvent(event);
// ── Step 9: Invalidate interactions query ──
// The checkpoint has changed, which shifts the query key
// (includes `processed_until`), so invalidation triggers a
// fresh fetch with the new `since` filter.
const coordinate = `31124:${companion.event.pubkey}:${companion.d}`;
queryClient.invalidateQueries({
queryKey: ['blobbi-interactions', coordinate],
});
return { consumedCount: result.consumedCount };
} finally {
setIsPending(false);
}
}, [publishEvent, updateCompanionEvent, queryClient]);
return { consolidate, isPending };
}
@@ -4,6 +4,9 @@
* This hook provides a local projection of decay without publishing events.
* It recalculates every 60 seconds while the component is mounted.
*
* When social interactions are provided, their effects are layered on top
* of the decayed stats. This is read-only projection — no 31124 mutation.
*
* The projected state is for UI display only. Actual mutations must
* recalculate from the persisted state before publishing.
*
@@ -14,6 +17,8 @@ import { useState, useEffect, useMemo } from 'react';
import type { BlobbiCompanion, BlobbiStats } from '../lib/blobbi';
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/blobbi/core/lib/blobbi-decay';
import { applySocialInteractions } from '@/blobbi/core/lib/blobbi-social-projection';
import { resolveSocialCheckpoint, type BlobbiInteraction } from '@/blobbi/core/lib/blobbi-interaction';
/** UI refresh interval in milliseconds (60 seconds) */
const UI_REFRESH_INTERVAL_MS = 60_000;
@@ -39,19 +44,22 @@ export interface ProjectedBlobbiState {
}
/**
* Hook to get a projected Blobbi state with decay applied.
* Hook to get a projected Blobbi state with decay and social interactions applied.
*
* Features:
* - Immediately calculates projected state on mount/companion change
* - Recalculates every 60 seconds while mounted
* - Applies social interaction effects on top of decay when provided
* - Pure calculation - does not publish any events
* - Returns both full stats and stage-appropriate visible stats
*
* @param companion - The persisted Blobbi companion (source of truth)
* @returns Projected state with decay applied, or null if no companion
* @param companion - The persisted Blobbi companion (source of truth)
* @param interactions - Optional sorted kind 1124 interactions to project on top of decay
* @returns Projected state with decay (and social effects) applied, or null if no companion
*/
export function useProjectedBlobbiState(
companion: BlobbiCompanion | null
companion: BlobbiCompanion | null,
interactions?: readonly BlobbiInteraction[],
): ProjectedBlobbiState | null {
// Track when we last recalculated
const [refreshTick, setRefreshTick] = useState(0);
@@ -73,7 +81,7 @@ export function useProjectedBlobbiState(
const now = Math.floor(Date.now() / 1000);
// Apply decay from persisted state
// Step 1: Apply decay from persisted state
const decayResult: DecayResult = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
@@ -82,41 +90,73 @@ export function useProjectedBlobbiState(
now,
});
// Step 2: Apply social interaction effects on top of decayed stats.
// Uses the canonical `resolveSocialCheckpoint()` so the projection layer
// shares the exact same checkpoint interpretation as the query layer.
// When valid, the checkpoint's last_event_id is used for boundary dedup.
// When invalid/absent (V1 fallback), no interactions are pre-excluded.
const resolved = resolveSocialCheckpoint(companion);
const finalStats = (interactions && interactions.length > 0)
? applySocialInteractions(
decayResult.stats,
interactions,
resolved.checkpoint,
)
: decayResult.stats;
// Get visible stats for the stage
const visibleStats = getVisibleStatsWithValues(companion.stage, decayResult.stats);
const visibleStats = getVisibleStatsWithValues(companion.stage, finalStats);
return {
stats: decayResult.stats,
stats: finalStats,
visibleStats,
elapsedSeconds: decayResult.elapsedSeconds,
projectedAt: now,
isFresh: true,
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTick triggers recalculation
}, [companion, refreshTick]);
}, [companion, interactions, refreshTick]);
return projectedState;
}
/**
* Calculate projected decay for a companion at a specific timestamp.
* Calculate projected decay for a companion at a specific timestamp,
* optionally layering social interaction effects on top.
*
* This is a utility function for use outside of React components,
* such as in mutation handlers before publishing.
* such as in feed card rendering (BlobbiStateCard).
*
* @param companion - The persisted Blobbi companion
* @param now - Unix timestamp to calculate decay to (defaults to current time)
* @returns Decay result with updated stats
* @param companion - The persisted Blobbi companion
* @param now - Unix timestamp to calculate decay to (defaults to current time)
* @param interactions - Optional sorted kind 1124 interactions to project
* @returns Decay result with socially-adjusted stats
*/
export function calculateProjectedDecay(
companion: BlobbiCompanion,
now?: number
now?: number,
interactions?: readonly BlobbiInteraction[],
): DecayResult {
return applyBlobbiDecay({
const result = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now: now ?? Math.floor(Date.now() / 1000),
});
if (interactions && interactions.length > 0) {
// Canonical checkpoint resolution — same path as the hook and query layer.
const resolved = resolveSocialCheckpoint(companion);
return {
...result,
stats: applySocialInteractions(
result.stats,
interactions,
resolved.checkpoint,
),
};
}
return result;
}
+390
View File
@@ -0,0 +1,390 @@
/**
* Blobbi Interaction Event (Kind 1124)
*
* Immutable interaction log events targeting a canonical Blobbi (kind 31124).
* These events do NOT directly mutate canonical state. They form an append-only
* log that can later be projected for social status or consolidated by the owner.
*
* Required tags:
* ["a", "31124:<owner-pubkey>:<blobbi-d-tag>"]
* ["p", "<owner-pubkey>"]
* ["action", "<action>"]
* ["source", "<source>"]
*
* Optional tags:
* ["blobbi", "<short-id>"]
* ["item", "<item-id>"]
* ["client", "<client-id>"] — added automatically by useNostrPublish
*
* @module blobbi-interaction
*/
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion } from './blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
export const KIND_BLOBBI_INTERACTION = 1124;
// ─── V1 Action Types ──────────────────────────────────────────────────────────
/**
* V1 interaction actions.
*
* `pet` is intentionally deferred from V1 — it does not map to any current
* owner flow and will be introduced in a later slice.
*/
export const INTERACTION_ACTIONS = ['feed', 'play', 'clean', 'medicate'] as const;
export type InteractionAction = typeof INTERACTION_ACTIONS[number];
/**
* Mapping from internal codebase action names to kind 1124 spec action names.
*
* | Internal | 1124 action |
* |-----------------|-------------|
* | feed | feed |
* | play | play |
* | clean | clean |
* | medicine | medicate |
* | play_music | play |
* | sing | play |
*
* Returns `undefined` for actions that should NOT emit a 1124 event (e.g.
* sleep toggle, streak bookkeeping).
*/
export const INTERNAL_TO_INTERACTION_ACTION: Record<string, InteractionAction | undefined> = {
feed: 'feed',
play: 'play',
clean: 'clean',
medicine: 'medicate',
play_music: 'play',
sing: 'play',
};
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Parsed representation of a kind 1124 Blobbi Interaction event.
*/
export interface BlobbiInteraction {
/** Original event */
event: NostrEvent;
/** The `a` tag coordinate (e.g. "31124:<owner>:<d>") */
blobbiCoordinate: string;
/** Owner pubkey from the `p` tag */
ownerPubkey: string;
/** V1 action name */
action: InteractionAction;
/** UI origin source */
source: string;
/** Short Blobbi ID from `blobbi` tag (optional) */
blobbiShortId: string | undefined;
/** Item used from `item` tag (optional) */
itemId: string | undefined;
/** Author pubkey of the interaction event */
authorPubkey: string;
/** Event created_at timestamp (unix seconds) */
createdAt: number;
}
/**
* Parameters needed to build a 1124 event template.
*/
export interface InteractionEventParams {
/** Pubkey of the Blobbi owner */
ownerPubkey: string;
/** The d-tag of the target Blobbi (kind 31124) */
blobbiDTag: string;
/** The interaction action */
action: InteractionAction;
/** UI surface that originated this interaction */
source: string;
/** Item ID used, if applicable */
itemId?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Extract the short Blobbi ID (10-hex petId) from a canonical d-tag.
* Returns `undefined` for non-canonical d-tags.
*
* Canonical format: `blobbi-{12 hex}-{10 hex}`
*/
export function extractBlobbiShortId(dTag: string): string | undefined {
const match = dTag.match(/^blobbi-[0-9a-f]{12}-([0-9a-f]{10})$/);
return match?.[1];
}
// ─── Builder ──────────────────────────────────────────────────────────────────
/**
* Build a kind 1124 event template ready for signing/publishing.
*
* The returned object can be passed directly to `useNostrPublish`'s
* `publishEvent()`. The `client` tag is added automatically by the hook.
*/
export function buildInteractionEventTemplate(params: InteractionEventParams): {
kind: number;
content: string;
tags: string[][];
} {
const coordinate = `31124:${params.ownerPubkey}:${params.blobbiDTag}`;
const tags: string[][] = [
['a', coordinate],
['p', params.ownerPubkey],
['action', params.action],
['source', params.source],
];
const shortId = extractBlobbiShortId(params.blobbiDTag);
if (shortId) {
tags.push(['blobbi', shortId]);
}
if (params.itemId) {
tags.push(['item', params.itemId]);
}
// NIP-31 alt tag for human-readable description
tags.push(['alt', `Blobbi interaction: ${params.action}`]);
return {
kind: KIND_BLOBBI_INTERACTION,
content: '',
tags,
};
}
// ─── Validator ────────────────────────────────────────────────────────────────
/**
* Validate that a NostrEvent is a well-formed kind 1124 interaction.
*
* Checks:
* - Correct kind
* - Has `a` tag starting with "31124:"
* - Has `p` tag (non-empty)
* - Has `action` tag with a recognised V1 value
* - Has `source` tag (non-empty)
*/
export function isValidInteractionEvent(event: NostrEvent): boolean {
return parseInteractionEvent(event) !== undefined;
}
// ─── Parser ───────────────────────────────────────────────────────────────────
/**
* Parse a NostrEvent into a typed BlobbiInteraction.
* Returns `undefined` if the event is invalid.
*/
export function parseInteractionEvent(event: NostrEvent): BlobbiInteraction | undefined {
if (event.kind !== KIND_BLOBBI_INTERACTION) return undefined;
const tags = event.tags;
const aTag = tags.find(([n]) => n === 'a')?.[1];
const pTag = tags.find(([n]) => n === 'p')?.[1];
const actionTag = tags.find(([n]) => n === 'action')?.[1];
const sourceTag = tags.find(([n]) => n === 'source')?.[1];
if (!aTag || !aTag.startsWith('31124:')) return undefined;
if (!pTag) return undefined;
if (!actionTag || !(INTERACTION_ACTIONS as readonly string[]).includes(actionTag)) return undefined;
if (!sourceTag) return undefined;
const blobbiTag = tags.find(([n]) => n === 'blobbi')?.[1];
const itemTag = tags.find(([n]) => n === 'item')?.[1];
return {
event,
blobbiCoordinate: aTag,
ownerPubkey: pTag,
action: actionTag as InteractionAction,
source: sourceTag,
blobbiShortId: blobbiTag,
itemId: itemTag,
authorPubkey: event.pubkey,
createdAt: event.created_at,
};
}
// ─── Deterministic Sort ───────────────────────────────────────────────────────
/**
* Sort interaction events deterministically for projection.
*
* Order: ascending `created_at`, then ascending event `id` (hex comparison)
* as tie-breaker. Returns a new array (does not mutate input).
*/
export function sortInteractionEvents(events: NostrEvent[]): NostrEvent[] {
return [...events].sort((a, b) => {
if (a.created_at !== b.created_at) return a.created_at - b.created_at;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
}
// ─── Fire-and-Forget Emitter ──────────────────────────────────────────────────
/**
* Publish a kind 1124 interaction event as a best-effort follow-up.
*
* This is a fire-and-forget helper: the returned promise is intentionally
* NOT awaited by the caller. If publication fails, a warning is logged and
* the canonical 31124 update (which already succeeded) is not affected.
*
* @param publishEvent - The `mutateAsync` function from `useNostrPublish`
* @param params - Interaction event parameters
*/
export function emitInteractionEvent(
publishEvent: (template: { kind: number; content: string; tags: string[][] }) => Promise<unknown>,
params: InteractionEventParams,
): void {
const template = buildInteractionEventTemplate(params);
publishEvent(template).catch((err: unknown) => {
console.warn('[Blobbi] Failed to publish interaction event (kind 1124):', err);
});
}
// ─── Social Checkpoint ────────────────────────────────────────────────────────
/**
* Social interaction checkpoint stored in kind 31124 content JSON.
*
* When present, indicates the owner has consolidated interactions up to
* `processed_until`. Clients use this as a `since` filter to avoid
* re-fetching already-consolidated events.
*/
export interface SocialCheckpoint {
/** Unix timestamp (seconds) up to which interactions have been processed */
processed_until: number;
/** Event id of the last processed interaction (for dedup at the boundary) */
last_event_id: string;
}
/**
* Resolved checkpoint result — discriminated union so consumers handle
* both states explicitly.
*/
export type ResolvedCheckpoint =
| { valid: true; checkpoint: SocialCheckpoint }
| { valid: false; checkpoint: undefined };
/**
* Parse a social checkpoint from kind 31124 content JSON.
*
* Returns `undefined` when:
* - content is empty or not valid JSON
* - `social_checkpoint` key is missing
* - either `processed_until` or `last_event_id` is missing/invalid
*
* **Strict validity**: both `processed_until` (positive number) and
* `last_event_id` (non-empty string) must be present. If either is
* missing or malformed, the entire checkpoint is treated as absent.
*
* Internal parser — callers should use `resolveSocialCheckpoint()`.
* Never throws.
*/
function parseSocialCheckpoint(content: string): SocialCheckpoint | undefined {
if (!content || !content.trim()) return undefined;
try {
const raw = JSON.parse(content);
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
const sc = raw.social_checkpoint;
if (typeof sc !== 'object' || sc === null || Array.isArray(sc)) return undefined;
if (typeof sc.processed_until !== 'number' || sc.processed_until <= 0) return undefined;
if (typeof sc.last_event_id !== 'string' || !sc.last_event_id) return undefined;
return { processed_until: sc.processed_until, last_event_id: sc.last_event_id };
} catch {
return undefined;
}
}
/**
* Canonical checkpoint resolution for kind 1124 social interactions.
*
* This is the **single entry point** for checkpoint interpretation. Both the
* query layer (useBlobbiInteractions — derives `since` filter) and the
* projection layer (applySocialInteractions — derives dedup seed) must
* call this function so their checkpoint interpretation cannot drift.
*
* ## Resolution rules
*
* 1. Parse `social_checkpoint` from `companion.event.content` JSON.
* 2. **Strict validity**: checkpoint is valid only when *both*
* `processed_until` (positive number) and `last_event_id` (non-empty
* string) are present. If either is missing or malformed, treat the
* entire checkpoint as absent.
* 3. **V1 no-checkpoint fallback** (explicit):
* - Query: fetch kind 1124 events WITHOUT a `since` filter (no prior
* consolidation is assumed). A finite relay-side limit still applies
* (currently 50 events — see `BLOBBI_INTERACTIONS_LIMIT`). This
* means the first 50 most-recent events are fetched, NOT the full
* history. This is a known V1 limitation.
* - Projection: do NOT pre-exclude any interaction. All fetched events
* are processed.
* 4. When checkpoint IS valid:
* - Query: set `since = checkpoint.processed_until`. Nostr `since` is
* inclusive (>=), so the boundary event may be re-fetched.
* - Projection: seed the dedup set with `checkpoint.last_event_id` so
* the boundary event is silently skipped.
*
* ## What this function does NOT do (V1 scope)
*
* - Does not advance the checkpoint
* - Does not consolidate or write back to kind 31124
* - Does not depend on `socialOpen` permission state
*
* @param companion - The Blobbi companion whose 31124 content may contain a checkpoint.
* Pass `null` when no companion is selected.
* @returns Discriminated union: `{ valid: true, checkpoint }` or `{ valid: false, checkpoint: undefined }`.
*/
export function resolveSocialCheckpoint(
companion: BlobbiCompanion | null,
): ResolvedCheckpoint {
if (!companion) {
return { valid: false, checkpoint: undefined };
}
const parsed = parseSocialCheckpoint(companion.event.content);
if (parsed) {
return { valid: true, checkpoint: parsed };
}
// V1 explicit fallback: no valid checkpoint found.
// Query will fetch without `since`; projection will not pre-exclude any event.
return { valid: false, checkpoint: undefined };
}
// ─── Social Checkpoint Serialization ──────────────────────────────────────────
/**
* Serialize a social checkpoint into kind 31124 content JSON.
*
* Follows the same pattern as `serializeEvolutionContent`: parses the existing
* content, preserves all unknown top-level keys (including `evolution`), and
* writes the `social_checkpoint` key.
*
* @param existingContent - The current 31124 content string (may be empty, non-JSON, or valid JSON).
* @param checkpoint - The new social checkpoint to write.
* @returns Stringified JSON with the updated `social_checkpoint`.
*/
export function serializeSocialCheckpoint(
existingContent: string,
checkpoint: SocialCheckpoint,
): string {
let base: Record<string, unknown> = {};
if (existingContent && existingContent.trim()) {
try {
const parsed = JSON.parse(existingContent);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
base = parsed;
}
} catch {
// Old-format text content — start fresh
}
}
return JSON.stringify({ ...base, social_checkpoint: checkpoint });
}
@@ -0,0 +1,173 @@
/**
* Social Projection — apply kind 1124 interactions to projected Blobbi stats.
*
* Pure function pipeline: takes already-decayed stats and a sorted list of
* parsed interactions, returns socially-adjusted stats for display.
*
* This module is read-only projection. It never mutates kind 31124 state,
* never advances checkpoints, and never publishes events.
*
* Processing rules:
* - Interactions are processed in ascending `created_at` order (caller
* must provide them pre-sorted via `sortInteractionEvents`).
* - Duplicate event IDs are skipped.
* - When an interaction carries an `itemId`, the shop item's `ItemEffect`
* is applied. Otherwise a small fallback effect per action is used.
* - All stats are clamped to [STAT_MIN, STAT_MAX] after each interaction.
*
* @module blobbi-social-projection
*/
import type { BlobbiStats } from './blobbi';
import { STAT_MIN, STAT_MAX } from './blobbi';
import type { BlobbiInteraction, InteractionAction, SocialCheckpoint } from './blobbi-interaction';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { ItemEffect } from '@/blobbi/shop/types/shop.types';
// ─── Fallback Effects ─────────────────────────────────────────────────────────
/**
* Default stat deltas applied when an interaction has no `itemId` or the
* item is not found in the shop catalog. Intentionally conservative —
* item-based interactions should always be preferred.
*/
const FALLBACK_EFFECTS: Record<InteractionAction, ItemEffect> = {
feed: { hunger: 10 },
play: { happiness: 10, energy: -5 },
clean: { hygiene: 15 },
medicate: { health: 10 },
};
// ─── Core ─────────────────────────────────────────────────────────────────────
/**
* Apply a list of kind 1124 interactions to already-decayed stats.
*
* @param baseStats - Full stats after decay projection (all 5 fields required).
* @param interactions - Parsed interactions, **must be sorted ascending** by
* `created_at` with id tie-break (as returned by
* `sortInteractionEvents` → `parseInteractionEvent`).
* @param checkpoint - Optional social checkpoint from the 31124 content.
* When present, the event identified by
* `checkpoint.last_event_id` is skipped (it was already
* consolidated into the canonical stats). This handles
* the Nostr `since` inclusive boundary.
* When absent (no prior consolidation), all interactions
* in the array are processed.
* @returns A new `BlobbiStats` object with social effects applied.
*/
export function applySocialInteractions(
baseStats: BlobbiStats,
interactions: readonly BlobbiInteraction[],
checkpoint?: SocialCheckpoint,
): BlobbiStats {
return consolidateSocialInteractions(baseStats, interactions, checkpoint).stats;
}
// ─── Consolidation ────────────────────────────────────────────────────────────
/**
* Result of consolidating social interactions into canonical stats.
*/
export interface ConsolidationResult {
/** New stats after applying all valid interactions */
stats: BlobbiStats;
/** Number of interactions that were actually applied (after dedup) */
consumedCount: number;
/** The last interaction that was actually applied, or `undefined` if none were consumed */
lastConsumed: BlobbiInteraction | undefined;
}
/**
* Consolidate social interactions into canonical stats, tracking which
* interactions were actually consumed.
*
* Uses the **exact same rules** as `applySocialInteractions` (same dedup,
* same item resolution, same effect application, same clamping) but also
* returns metadata about what was consumed so the caller can advance the
* checkpoint accurately.
*
* @param baseStats - Full stats after decay (all 5 fields required).
* @param interactions - Parsed interactions, **must be sorted ascending**.
* @param checkpoint - Optional existing checkpoint for dedup seeding.
* @returns Consolidation result with new stats and consumed interaction info.
*/
export function consolidateSocialInteractions(
baseStats: BlobbiStats,
interactions: readonly BlobbiInteraction[],
checkpoint?: SocialCheckpoint,
): ConsolidationResult {
if (interactions.length === 0) {
return { stats: baseStats, consumedCount: 0, lastConsumed: undefined };
}
// Mutable working copy
const stats: BlobbiStats = { ...baseStats };
// Dedup set — general relay-duplicate safety net (same role as in
// applySocialInteractions). Boundary event is already filtered upstream.
const seen = new Set<string>();
if (checkpoint) {
seen.add(checkpoint.last_event_id);
}
let consumedCount = 0;
let lastConsumed: BlobbiInteraction | undefined;
for (const ix of interactions) {
// ── Dedup (also handles checkpoint boundary) ──
if (seen.has(ix.event.id)) continue;
seen.add(ix.event.id);
// ── Resolve effect ──
const effect = resolveEffect(ix);
// ── Apply ──
applyEffect(stats, effect);
consumedCount++;
lastConsumed = ix;
}
return { stats, consumedCount, lastConsumed };
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Resolve the stat effect for a single interaction.
*
* Priority:
* 1. Shop item effect (when `itemId` is present and resolves to a known item)
* 2. Fallback per-action effect
*/
function resolveEffect(ix: BlobbiInteraction): ItemEffect {
if (ix.itemId) {
const item = getShopItemById(ix.itemId);
if (item?.effect) return item.effect;
}
return FALLBACK_EFFECTS[ix.action];
}
/** Apply an `ItemEffect` to mutable stats, clamping each field. */
function applyEffect(stats: BlobbiStats, effect: ItemEffect): void {
if (effect.hunger !== undefined) {
stats.hunger = clamp(stats.hunger + effect.hunger);
}
if (effect.happiness !== undefined) {
stats.happiness = clamp(stats.happiness + effect.happiness);
}
if (effect.health !== undefined) {
stats.health = clamp(stats.health + effect.health);
}
if (effect.hygiene !== undefined) {
stats.hygiene = clamp(stats.hygiene + effect.hygiene);
}
if (effect.energy !== undefined) {
stats.energy = clamp(stats.energy + effect.energy);
}
}
function clamp(value: number): number {
return Math.max(STAT_MIN, Math.min(STAT_MAX, value));
}
+13
View File
@@ -534,6 +534,19 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [
// ═══════════════════════════════════════════════════════════════════════════
// SOCIAL / FLAG TAGS
// ═══════════════════════════════════════════════════════════════════════════
{
tag: 'social',
description: 'Whether external users can interact with this Blobbi via kind 1124 events',
category: 'social',
required: false,
stages: ['egg', 'baby', 'adult'],
persistent: true,
source: 'user',
regenerable: false,
format: 'open | closed',
defaultValue: 'closed',
notes: 'Controls the external social interaction gate. Absent or "closed" = denied. Owner interactions bypass this check.',
},
{
tag: 'breeding_ready',
description: 'Whether the Blobbi is eligible for breeding',
+22 -2
View File
@@ -273,6 +273,8 @@ export interface BlobbiCompanion {
generation: number | undefined;
/** Breeding eligibility */
breedingReady: boolean;
/** Whether external users can interact with this Blobbi (social tag = "open") */
socialOpen: boolean;
/** Total XP */
experience: number | undefined;
/** Consecutive care days */
@@ -1248,6 +1250,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
},
generation: parseNumericTag(tags, 'generation'),
breedingReady: parseBooleanTag(tags, 'breeding_ready', false),
socialOpen: getTagValue(tags, 'social') === 'open',
experience: parseNumericTag(tags, 'experience'),
careStreak: parseNumericTag(tags, 'care_streak'),
careStreakLastAt: parseNumericTag(tags, 'care_streak_last_at'),
@@ -1394,7 +1397,7 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([
// Progression tags
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
// Social/flag tags
'breeding_ready',
'social', 'breeding_ready',
// Progression tags (orthogonal to activity state)
'progression_state', 'progression_started_at',
// Task system tags (removed after stage transitions)
@@ -1557,6 +1560,23 @@ function syncMirrorTagsToSeed(tags: string[][]): string[][] {
return filtered;
}
/**
* Build the stat + timestamp tag updates for a Blobbi state publish.
* Serializes all 5 stats to strings and sets both decay/interaction timestamps.
*/
export function statsToTagUpdates(stats: BlobbiStats, now: number): Record<string, string> {
const nowStr = now.toString();
return {
hunger: stats.hunger.toString(),
happiness: stats.happiness.toString(),
health: stats.health.toString(),
hygiene: stats.hygiene.toString(),
energy: stats.energy.toString(),
last_decay_at: nowStr,
last_interaction: nowStr,
};
}
/**
* Update specific tags in a Blobbi event while preserving unknown tags.
* Uses MANAGED_BLOBBI_STATE_TAG_NAMES for Kind 31124.
@@ -1867,7 +1887,7 @@ export function buildMigrationTags(
// Legacy progression timing (also preserve for fallback)
'state_started_at',
// Social/flag tags
'generation', 'breeding_ready',
'social', 'generation', 'breeding_ready',
// Personality tags (preserve if they exist, do NOT generate)
'personality', 'trait', 'favorite_food', 'voice_type', 'mood',
// Evolution tags
@@ -188,6 +188,7 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
lastDecayAt: preview.createdAt,
generation: 1,
breedingReady: false,
socialOpen: false,
experience: 0,
careStreak: 1,
careStreakLastAt: preview.createdAt,
+25 -4
View File
@@ -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,
@@ -14,6 +14,8 @@ import {
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { SegmentedRing } from '@/blobbi/ui/StatIndicator';
import { ReactionSparkles, ReactionBubbles } from '@/blobbi/ui/ReactionOverlays';
import { FloatingSocialHearts } from '@/blobbi/ui/FloatingSocialHearts';
import { getVisibleStats } from '@/blobbi/core/lib/blobbi-decay';
import { getBlobbiStatDisplayState } from '@/blobbi/core/lib/blobbi-segments';
import type { CareState } from '@/blobbi/core/lib/blobbi-segments';
@@ -21,6 +23,7 @@ import type { BlobbiCompanion, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BlobbiReactionState } from '@/blobbi/actions';
import type { InteractionReactionState } from '@/blobbi/ui/hooks/useInteractionReaction';
import type { BlobbiRoomId } from '../lib/room-config';
import { ROOM_META, DEFAULT_ROOM_ORDER, getRoomIndex } from '../lib/room-config';
import { cn } from '@/lib/utils';
@@ -81,6 +84,8 @@ export interface BlobbiRoomHeroProps {
roomId: BlobbiRoomId;
/** Room order for dot indicators */
roomOrder?: BlobbiRoomId[];
/** Temporary interaction reaction state (sparkles, bubbles, hearts, body animation). */
interactionReaction?: InteractionReactionState;
/** Called when the user taps any stat icon to start the guide. */
onGuide?: (stat: keyof BlobbiStats) => void;
className?: string;
@@ -88,7 +93,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,
@@ -104,6 +115,7 @@ export function BlobbiRoomHero({
heroRef,
heroWidth,
roomId,
interactionReaction,
roomOrder = DEFAULT_ROOM_ORDER,
onGuide,
className,
@@ -146,7 +158,12 @@ export function BlobbiRoomHero({
<StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} onGuide={onGuide} />
<div
className={cn('relative transition-all duration-500', !isEgg && 'pointer-events-none')}
data-blobbi-visual
className={cn(
'relative transition-all duration-500',
!isEgg && 'pointer-events-none',
interactionReaction?.bodyAnimation,
)}
style={!isSleeping ? {
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
} : undefined}
@@ -165,6 +182,10 @@ export function BlobbiRoomHero({
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
}
/>
{/* Interaction reaction overlays — sparkles, bubbles, hearts */}
<ReactionSparkles active={interactionReaction?.sparkles ?? false} />
<ReactionBubbles active={interactionReaction?.bubbles ?? false} />
<FloatingSocialHearts active={interactionReaction?.hearts ?? false} />
</div>
{!isEgg && (
@@ -197,7 +218,7 @@ export function BlobbiRoomHero({
</div>
</div>
);
}
});
// ─── Stats Crown ──────────────────────────────────────────────────────────────
@@ -94,6 +94,11 @@ export function BlobbiRoomShell({
const touchStartX = useReactRef<number | null>(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]);
+34 -14
View File
@@ -26,9 +26,17 @@ interface ItemCarouselProps {
onFocusChange?: (entry: CarouselEntry) => void;
/** When set, the carousel visually guides the user toward this item. */
highlightId?: string | null;
/** When set, seeds the initial index to this item's position. */
initialItemId?: 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;
/** Seed the initial focused item by id (e.g. from localStorage). Falls back to index 0. */
initialItemId?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
@@ -40,8 +48,9 @@ export function ItemCarousel({
disabled,
onFocusChange,
highlightId,
className,
centerPointerHandlers,
initialItemId,
className,
}: ItemCarouselProps) {
const [index, setIndex] = useState(() => {
if (initialItemId) {
@@ -72,9 +81,16 @@ export function ItemCarousel({
});
}, [items, count]);
// Clamp synchronously: the effect above resets state *after* render, so on
// the first render with a shorter items array the stale index can exceed
// the new length. Using the clamped value for all reads below prevents the
// out-of-bounds access that would otherwise crash.
const safeIndex = count === 0 ? 0 : Math.min(index, count - 1);
const prev = useCallback(() => {
setIndex(i => {
const n = (i - 1 + count) % count;
const clamped = Math.min(i, count - 1);
const n = (clamped - 1 + count) % count;
onFocusChange?.(items[n]);
return n;
});
@@ -82,7 +98,8 @@ export function ItemCarousel({
const next = useCallback(() => {
setIndex(i => {
const n = (i + 1) % count;
const clamped = Math.min(i, count - 1);
const n = (clamped + 1) % count;
onFocusChange?.(items[n]);
return n;
});
@@ -94,14 +111,14 @@ export function ItemCarousel({
const highlightArrow = useMemo<'left' | 'right' | null>(() => {
if (!highlightId || count < 2) return null;
const targetIdx = items.findIndex(i => i.id === highlightId);
if (targetIdx === -1 || targetIdx === index) return null;
if (targetIdx === -1 || targetIdx === safeIndex) return null;
const rightDist = (targetIdx - index + count) % count;
const leftDist = (index - targetIdx + count) % count;
const rightDist = (targetIdx - safeIndex + count) % count;
const leftDist = (safeIndex - targetIdx + count) % count;
return rightDist <= leftDist ? 'right' : 'left';
}, [highlightId, items, index, count]);
}, [highlightId, items, safeIndex, count]);
const isHighlightFocused = !!highlightId && items[index]?.id === highlightId;
const isHighlightFocused = !!highlightId && items[safeIndex]?.id === highlightId;
if (count === 0) {
return (
@@ -111,9 +128,9 @@ export function ItemCarousel({
);
}
const current = items[index];
const prevItem = items[(index - 1 + count) % count];
const nextItem = items[(index + 1) % count];
const current = items[safeIndex];
const prevItem = items[(safeIndex - 1 + count) % count];
const nextItem = items[(safeIndex + 1) % count];
const isThisActive = activeItemId === current.id;
const showPreviews = count >= 3;
@@ -144,8 +161,10 @@ export function ItemCarousel({
)}
<button
onClick={() => onUse(current.id)}
onClick={centerPointerHandlers ? undefined : () => onUse(current.id)}
onPointerDown={centerPointerHandlers ? (e: React.PointerEvent<HTMLButtonElement>) => centerPointerHandlers.onPointerDown(e, current) : undefined}
disabled={disabled}
data-food-drag={centerPointerHandlers ? '' : undefined}
className={cn(
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
@@ -154,6 +173,7 @@ export function ItemCarousel({
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
isHighlightFocused && 'ring-2 ring-primary/60',
centerPointerHandlers && 'touch-none',
)}
style={isHighlightFocused ? { animation: 'guide-glow-slow 1.1s linear infinite' } as CSSProperties : undefined}
>
+259
View File
@@ -0,0 +1,259 @@
/**
* useFoodDrag — Manages drag-to-feed interaction for food items.
*
* Architecture:
* - The carousel button only handles `pointerdown` to START a drag.
* - Once started, the hook attaches native `window` listeners for
* `pointermove`, `pointerup`, `pointercancel`, and `blur`.
* - These global listeners own the drag until it ends.
* - The carousel button plays no further role in the drag lifecycle.
*
* This eliminates all bugs caused by:
* - Button-level handlers being swapped out by React re-renders
* - Pointer capture being released before state cleanup
* - Coalesced events firing between capture release and ref clear
* - Stale closures from React batching or handler recreation
*
* Performance:
* Pointer position is tracked via refs and applied to the ghost element
* through direct DOM mutation. React state only changes on drag
* start / end — never during pointermove — so the parent tree stays still.
*
* Safety:
* - `activePointerIdRef` rejects events from any other pointer.
* - `sessionRef` (monotonic counter) invalidates stale listeners.
* - `cleanup()` is idempotent and runs on end, cancel, blur, and unmount.
*/
import { useState, useCallback, useRef, useEffect } from 'react';
// ─── Debug ────────────────────────────────────────────────────────────────────
/** Flip to `true` during development to trace the full drag lifecycle. */
const DEBUG_FOOD_DRAG = import.meta.env.DEV && false;
function dbg(...args: unknown[]) {
if (DEBUG_FOOD_DRAG) console.log('[food-drag]', ...args);
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Distance (px) from mouth center to trigger open-mouth / drop-to-eat. */
const MOUTH_THRESHOLD = 80;
/** Mouth anchor as proportion of the visual container. */
const MOUTH_X_RATIO = 0.5;
const MOUTH_Y_RATIO = 0.67;
/** Sentinel: no pointer is active. */
const NO_POINTER = -1;
// ─── Types ────────────────────────────────────────────────────────────────────
/** Drag identity exposed via React state (drives mount/unmount of the ghost). */
export interface FoodDragState {
itemId: string;
emoji: string;
/** Initial pointer coords — seeds the ghost's first-paint position. */
startX: number;
startY: number;
}
export interface UseFoodDragReturn {
/** Non-null while a drag is active. Drives ghost rendering. */
drag: FoodDragState | null;
/** Attach to the ghost overlay div. The hook positions it directly. */
ghostRef: React.RefObject<HTMLDivElement | null>;
/** Call on pointerdown on a food item to begin a drag session.
* After this, the hook owns the lifecycle via global listeners. */
onDragStart: (e: React.PointerEvent, itemId: string, emoji: string) => void;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getMouthCenter(): { mx: number; my: number } | null {
const el = document.querySelector<HTMLElement>('[data-blobbi-visual]');
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
mx: rect.left + rect.width * MOUTH_X_RATIO,
my: rect.top + rect.height * MOUTH_Y_RATIO,
};
}
function isNearMouth(px: number, py: number): boolean {
const mouth = getMouthCenter();
if (!mouth) return false;
const dx = px - mouth.mx;
const dy = py - mouth.my;
return Math.sqrt(dx * dx + dy * dy) <= MOUTH_THRESHOLD;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useFoodDrag(
onFeed: (itemId: string) => void,
onNearMouthChange?: (near: boolean) => void,
): UseFoodDragReturn {
// React state — only set on drag start (non-null) and end (null).
const [drag, setDrag] = useState<FoodDragState | null>(null);
// Mutable session state — read/written only in native event handlers.
const activePointerIdRef = useRef(NO_POINTER);
const itemIdRef = useRef<string | null>(null);
const nearRef = useRef(false);
const sessionRef = useRef(0); // monotonic; stale listeners bail
// Latest callbacks — stored in refs so global listeners always call
// the freshest version without needing to detach/reattach.
const onFeedRef = useRef(onFeed);
onFeedRef.current = onFeed;
const onNearMouthChangeRef = useRef(onNearMouthChange);
onNearMouthChangeRef.current = onNearMouthChange;
// Ghost DOM ref — positioned imperatively, never through React state.
const ghostRef = useRef<HTMLDivElement | null>(null);
// ── Ghost helpers ──────────────────────────────────────────────────────
const moveGhost = useCallback((x: number, y: number) => {
const el = ghostRef.current;
if (!el) return;
el.style.display = '';
el.style.left = `${x}px`;
el.style.top = `${y}px`;
}, []);
const hideGhost = useCallback(() => {
const el = ghostRef.current;
if (!el) return;
el.style.display = 'none';
if (el.firstElementChild) {
el.firstElementChild.classList.remove('scale-75');
}
}, []);
// ── Cleanup (idempotent) ───────────────────────────────────────────────
// Stored in a ref so the native listeners can call it without depending
// on a useCallback identity that might change between attach and detach.
const cleanupRef = useRef<(() => void) | null>(null);
/** End the drag session. Safe to call multiple times. */
const endSession = useCallback((reason: string) => {
if (activePointerIdRef.current === NO_POINTER) return; // already ended
dbg('end-session', reason, 'pointer', activePointerIdRef.current);
activePointerIdRef.current = NO_POINTER;
itemIdRef.current = null;
nearRef.current = false;
hideGhost();
setDrag(null);
// Detach global listeners.
cleanupRef.current?.();
cleanupRef.current = null;
}, [hideGhost]);
// ── Start ──────────────────────────────────────────────────────────────
const onDragStart = useCallback((e: React.PointerEvent, itemId: string, emoji: string) => {
// If a drag is already active (shouldn't happen, but defensive), end it.
if (activePointerIdRef.current !== NO_POINTER) {
endSession('new-drag-while-active');
}
e.preventDefault();
e.stopPropagation();
const pid = e.pointerId;
const x = e.clientX;
const y = e.clientY;
const session = ++sessionRef.current;
activePointerIdRef.current = pid;
itemIdRef.current = itemId;
nearRef.current = false;
dbg('start', { itemId, pid, x, y, session });
// Mount ghost via React state.
setDrag({ itemId, emoji, startX: x, startY: y });
// ── Global listeners (native) ────────────────────────────────────
const onMove = (ev: PointerEvent) => {
if (ev.pointerId !== pid || session !== sessionRef.current) {
dbg('move-ignored', { evPid: ev.pointerId, pid, evSession: session, current: sessionRef.current });
return;
}
moveGhost(ev.clientX, ev.clientY);
// Near-mouth scale class.
const ghost = ghostRef.current;
if (ghost?.firstElementChild) {
const near = isNearMouth(ev.clientX, ev.clientY);
ghost.firstElementChild.classList.toggle('scale-75', near);
if (near !== nearRef.current) {
nearRef.current = near;
onNearMouthChangeRef.current?.(near);
}
}
};
const onUp = (ev: PointerEvent) => {
if (ev.pointerId !== pid || session !== sessionRef.current) return;
const feedItemId = itemIdRef.current;
const didFeed = feedItemId ? isNearMouth(ev.clientX, ev.clientY) : false;
const wasMouthOpen = nearRef.current;
dbg('pointerup', { pid, x: ev.clientX, y: ev.clientY, didFeed, feedItemId });
endSession(didFeed ? 'feed' : 'miss');
if (didFeed && feedItemId) {
onFeedRef.current(feedItemId);
} else if (wasMouthOpen) {
onNearMouthChangeRef.current?.(false);
}
};
const onCancel = (ev: PointerEvent) => {
if (ev.pointerId !== pid || session !== sessionRef.current) return;
const wasMouthOpen = nearRef.current;
dbg('pointercancel', { pid });
endSession('cancel');
if (wasMouthOpen) onNearMouthChangeRef.current?.(false);
};
const onBlur = () => {
if (session !== sessionRef.current) return;
const wasMouthOpen = nearRef.current;
dbg('blur');
endSession('blur');
if (wasMouthOpen) onNearMouthChangeRef.current?.(false);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onCancel);
window.addEventListener('blur', onBlur);
dbg('listeners-attached', { session });
cleanupRef.current = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onCancel);
window.removeEventListener('blur', onBlur);
dbg('listeners-detached', { session });
};
}, [endSession, moveGhost]);
// Cleanup on unmount.
useEffect(() => () => {
endSession('unmount');
}, [endSession]);
return { drag, ghostRef, onDragStart };
}
+82
View File
@@ -0,0 +1,82 @@
/**
* FloatingSocialHearts - Animated heart overlay for social interactions.
*
* Renders small floating heart symbols around the Blobbi when recent
* social interactions (kind 1124) exist. Uses the same overlay pattern
* as FloatingMusicNotes — CSS keyframe animation, absolute positioning,
* and `prefers-reduced-motion` support.
*
* This is a pure visual cue. It does not change projection logic, does
* not depend on stat thresholds, and is triggered solely by the presence
* of projected social interactions.
*/
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface FloatingSocialHeartsProps {
/** Whether to show the floating hearts */
active: boolean;
/** Additional CSS classes */
className?: string;
}
// ─── Configuration ────────────────────────────────────────────────────────────
/**
* Heart configurations for positioning and timing.
* Fewer items and slower cadence than music notes for a subtle effect.
*/
const HEART_CONFIGS = [
{ symbol: '💗', left: '15%', delay: '0s', duration: '3.5s' },
{ symbol: '✨', left: '80%', delay: '1.2s', duration: '3s' },
{ symbol: '💗', left: '55%', delay: '2.4s', duration: '3.8s' },
] as const;
// ─── Component ────────────────────────────────────────────────────────────────
/**
* Renders floating heart/sparkle emojis around the Blobbi.
*
* Position this component as an overlay on the Blobbi container.
* Hearts drift upward and fade, signaling that social care was received.
*/
export function FloatingSocialHearts({ active, className }: FloatingSocialHeartsProps) {
const hearts = useMemo(() => (
HEART_CONFIGS.map((config, index) => (
<span
key={index}
className={cn(
'absolute pointer-events-none select-none',
'animate-social-heart-float text-sm',
)}
style={{
left: config.left,
bottom: '25%',
animationDelay: config.delay,
animationDuration: config.duration,
}}
>
{config.symbol}
</span>
))
), []);
if (!active) {
return null;
}
return (
<div
className={cn(
'absolute inset-0 overflow-hidden pointer-events-none',
className,
)}
aria-hidden="true"
>
{hearts}
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* ReactionOverlays — Temporary particle overlays for care-action reactions.
*
* Each component follows the same pattern as FloatingMusicNotes and
* FloatingSocialHearts: absolute-positioned overlay, CSS keyframe animation,
* prefers-reduced-motion support. They are designed to be layered inside the
* BlobbiStageVisual or BlobbiRoomHero container.
*
* Components:
* - ReactionSparkles: CSS sparkle shapes after cleaning
* - ReactionBubbles: bubble wash covering Blobbi (clean_complete phase 1)
*/
import { cn } from '@/lib/utils';
// ─── Sparkles ────────────────────────────────────────────────────────────────
export interface ReactionSparklesProps {
active: boolean;
className?: string;
}
/**
* Sparkle particle positions — balanced around the Blobbi body center.
* Horizontal spread is tightened (20%-80%) to cluster around the body
* rather than appearing at the container edges on larger adult sizes.
*/
const SPARKLE_CONFIGS = [
{ left: '22%', top: '18%', delay: '0s', scale: 0.8 },
{ left: '72%', top: '22%', delay: '0.2s', scale: 1.0 },
{ left: '30%', top: '65%', delay: '0.4s', scale: 0.6 },
{ left: '68%', top: '60%', delay: '0.15s', scale: 0.85 },
{ left: '48%', top: '12%', delay: '0.35s', scale: 1.0 },
{ left: '20%', top: '42%', delay: '0.5s', scale: 0.7 },
{ left: '78%', top: '45%', delay: '0.1s', scale: 0.75 },
{ left: '55%', top: '72%', delay: '0.25s', scale: 0.65 },
{ left: '38%', top: '35%', delay: '0.45s', scale: 0.9 },
] as const;
/**
* Sparkle particles that appear around the Blobbi after cleaning.
* Uses CSS 4-point star shapes instead of emoji for a polished effect.
*/
export function ReactionSparkles({ active, className }: ReactionSparklesProps) {
if (!active) return null;
return (
<div
className={cn('absolute inset-0 overflow-hidden pointer-events-none', className)}
aria-hidden="true"
>
{SPARKLE_CONFIGS.map((config, i) => (
<span
key={i}
className="absolute pointer-events-none select-none animate-reaction-sparkle"
style={{
left: config.left,
top: config.top,
animationDelay: config.delay,
}}
>
{/* 4-point CSS star sparkle */}
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
style={{ transform: `scale(${config.scale})` }}
>
<path
d="M8 0 L9.5 6.5 L16 8 L9.5 9.5 L8 16 L6.5 9.5 L0 8 L6.5 6.5 Z"
fill="currentColor"
className="text-yellow-300"
/>
<path
d="M8 3 L8.8 6.8 L12 8 L8.8 9.2 L8 13 L7.2 9.2 L4 8 L7.2 6.8 Z"
fill="white"
opacity="0.7"
/>
</svg>
</span>
))}
</div>
);
}
// ─── Bubbles ─────────────────────────────────────────────────────────────────
export interface ReactionBubblesProps {
active: boolean;
className?: string;
}
/**
* Bubble configs — dense coverage across the full overlay to obscure Blobbi.
* Uses many bubbles at varying depths to create a "covered in suds" effect.
*/
const BUBBLE_CONFIGS = [
// Bottom layer
{ left: '15%', bottom: '5%', delay: '0s', size: 22 },
{ left: '40%', bottom: '3%', delay: '0.05s', size: 26 },
{ left: '65%', bottom: '8%', delay: '0.08s', size: 20 },
{ left: '85%', bottom: '6%', delay: '0.03s', size: 24 },
// Lower-mid layer
{ left: '25%', bottom: '18%', delay: '0.1s', size: 28 },
{ left: '55%', bottom: '15%', delay: '0.12s', size: 24 },
{ left: '75%', bottom: '20%', delay: '0.06s', size: 22 },
{ left: '10%', bottom: '22%', delay: '0.14s', size: 20 },
// Mid layer
{ left: '35%', bottom: '30%', delay: '0.08s', size: 30 },
{ left: '60%', bottom: '32%', delay: '0.15s', size: 26 },
{ left: '20%', bottom: '35%', delay: '0.1s', size: 24 },
{ left: '80%', bottom: '33%', delay: '0.12s', size: 22 },
// Upper-mid layer
{ left: '45%', bottom: '42%', delay: '0.06s', size: 28 },
{ left: '15%', bottom: '48%', delay: '0.16s', size: 22 },
{ left: '70%', bottom: '45%', delay: '0.09s', size: 26 },
// Top layer
{ left: '30%', bottom: '55%', delay: '0.12s', size: 24 },
{ left: '55%', bottom: '58%', delay: '0.04s', size: 20 },
{ left: '78%', bottom: '52%', delay: '0.14s', size: 22 },
] as const;
/**
* Bubble wash overlay that covers the Blobbi during clean_complete phase 1.
* Uses CSS circles with gradients instead of emoji for a sudsy, covering effect.
*/
export function ReactionBubbles({ active, className }: ReactionBubblesProps) {
if (!active) return null;
return (
<div
className={cn('absolute inset-0 overflow-hidden pointer-events-none z-10', className)}
aria-hidden="true"
>
{/* Semi-opaque backdrop to partially obscure Blobbi */}
<div className="absolute inset-0 bg-sky-100/40 dark:bg-sky-900/30 animate-reaction-bubble-backdrop rounded-full" />
{BUBBLE_CONFIGS.map((config, i) => (
<span
key={i}
className="absolute pointer-events-none select-none animate-reaction-bubble"
style={{
left: config.left,
bottom: config.bottom,
animationDelay: config.delay,
width: config.size,
height: config.size,
}}
>
{/* CSS bubble with highlight */}
<span
className="block w-full h-full rounded-full"
style={{
background: 'radial-gradient(circle at 35% 30%, rgba(255,255,255,0.8) 0%, rgba(186,230,253,0.6) 40%, rgba(125,211,252,0.3) 70%, transparent 100%)',
border: '1px solid rgba(125,211,252,0.5)',
}}
/>
</span>
))}
</div>
);
}
@@ -0,0 +1,247 @@
/**
* useInteractionReaction — Temporary visual reward reactions for care actions.
*
* Manages a short-lived reaction that overrides the Blobbi's facial expression
* and triggers particle overlays (sparkles, bubbles, hearts) for a fixed
* duration after a care action succeeds.
*
* This layer sits *above* the persistent status-reaction system:
* 1. When active, the reaction's recipe override and body animation are used
* 2. When the reaction expires, control returns to useStatusReaction
*
* Reactions are purely ephemeral — nothing is persisted, published, or encoded
* into kind 31124. The hook manages its own lifecycle via setTimeout.
*
* @module useInteractionReaction
*/
import { useState, useCallback, useRef } from 'react';
import type { BlobbiEmotion } from '../lib/emotion-types';
// ─── Types ───────────────────────────────────────────────────────────────────
/**
* Interaction types that produce unique visual reactions.
* `clean_complete` is a special variant detected when cleaning removes
* the visibly dirty state entirely.
*/
export type InteractionReactionType =
| 'feed'
| 'medicate'
| 'play'
| 'clean'
| 'clean_complete'
| 'social_hearts';
/** Phase of a multi-phase reaction (e.g. clean_complete: bubbles → sparkles). */
type ReactionPhase = 'primary' | 'secondary';
/** Active reaction state. */
interface ActiveReaction {
type: InteractionReactionType;
phase: ReactionPhase;
/** Emotion override for the status reaction system. */
emotion: BlobbiEmotion | null;
/** CSS class to add to the Blobbi animation container. */
bodyAnimation: string | null;
/** Whether to show sparkle particles. */
sparkles: boolean;
/** Whether to show bubble wash overlay. */
bubbles: boolean;
/** Whether to show floating hearts. */
hearts: boolean;
}
/** Public state returned by the hook. */
export interface InteractionReactionState {
/** Current emotion override (null = no override, status system drives). */
emotionOverride: BlobbiEmotion | null;
/** CSS animation class for the body container. */
bodyAnimation: string | null;
/** Show sparkle overlay. */
sparkles: boolean;
/** Show bubble wash overlay. */
bubbles: boolean;
/** Show floating hearts. */
hearts: boolean;
/** Whether any reaction is currently active. */
isActive: boolean;
}
export interface UseInteractionReactionReturn {
/** Current reaction visual state. */
state: InteractionReactionState;
/** Trigger a new interaction reaction. Replaces any in-progress reaction. */
trigger: (type: InteractionReactionType) => void;
}
/**
* Maps InventoryAction names (used by the care/item system) to
* InteractionReactionType names (used by the visual reaction layer).
*
* Shared by NoteCard, PostDetailPage, and any surface that triggers
* interaction reactions on behalf of a social action.
*/
export const INVENTORY_TO_REACTION: Record<string, InteractionReactionType> = {
feed: 'feed',
play: 'play',
clean: 'clean',
medicine: 'medicate',
};
// ─── Reaction Definitions ────────────────────────────────────────────────────
/**
* Durations in milliseconds for each reaction type / phase.
*/
const REACTION_DURATIONS: Record<InteractionReactionType, { primary: number; secondary?: number }> = {
feed: { primary: 1800 },
medicate: { primary: 1800 },
play: { primary: 2000 },
clean: { primary: 1500 },
clean_complete: { primary: 1200, secondary: 1500 },
social_hearts: { primary: 2500 },
};
/**
* Build the ActiveReaction for a given type and phase.
*/
function buildReaction(type: InteractionReactionType, phase: ReactionPhase): ActiveReaction {
switch (type) {
case 'feed':
// Closed happy eyes (^_^ squint), big smile, gentle wiggle
return {
type, phase,
emotion: 'blissful',
bodyAnimation: 'animate-reaction-wiggle',
sparkles: false, bubbles: false, hearts: false,
};
case 'medicate':
// Adoring eyes (white dots), reluctant mouth → 'adoring' maps to
// watery eyes (glistening white circles) + small round mouth
return {
type, phase,
emotion: 'adoring',
bodyAnimation: null,
sparkles: false, bubbles: false, hearts: false,
};
case 'play':
// Star eyes, joyful bouncing
return {
type, phase,
emotion: 'excited',
bodyAnimation: 'animate-reaction-bounce',
sparkles: false, bubbles: false, hearts: false,
};
case 'clean':
// Sparkles around Blobbi
return {
type, phase,
emotion: null,
bodyAnimation: null,
sparkles: true, bubbles: false, hearts: false,
};
case 'clean_complete':
if (phase === 'primary') {
// Phase 1: bubbles cover Blobbi
return {
type, phase,
emotion: null,
bodyAnimation: null,
sparkles: false, bubbles: true, hearts: false,
};
}
// Phase 2: bubbles gone, sparkles appear, blissful face
return {
type, phase,
emotion: 'blissful',
bodyAnimation: null,
sparkles: true, bubbles: false, hearts: false,
};
case 'social_hearts':
// Subtle floating hearts
return {
type, phase,
emotion: null,
bodyAnimation: null,
sparkles: false, bubbles: false, hearts: true,
};
}
}
// ─── Idle state constant ─────────────────────────────────────────────────────
const IDLE_STATE: InteractionReactionState = {
emotionOverride: null,
bodyAnimation: null,
sparkles: false,
bubbles: false,
hearts: false,
isActive: false,
};
// ─── Hook ────────────────────────────────────────────────────────────────────
export function useInteractionReaction(): UseInteractionReactionReturn {
const [reaction, setReaction] = useState<ActiveReaction | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const schedulePhase = useCallback((
type: InteractionReactionType,
phase: ReactionPhase,
delay: number,
) => {
clearTimer();
timerRef.current = setTimeout(() => {
const durations = REACTION_DURATIONS[type];
if (phase === 'primary' && durations.secondary !== undefined) {
// Transition to secondary phase
setReaction(buildReaction(type, 'secondary'));
timerRef.current = setTimeout(() => {
setReaction(null);
timerRef.current = null;
}, durations.secondary);
} else {
// End reaction
setReaction(null);
timerRef.current = null;
}
}, delay);
}, [clearTimer]);
const trigger = useCallback((type: InteractionReactionType) => {
clearTimer();
const initial = buildReaction(type, 'primary');
setReaction(initial);
const durations = REACTION_DURATIONS[type];
schedulePhase(type, 'primary', durations.primary);
}, [clearTimer, schedulePhase]);
const state: InteractionReactionState = reaction
? {
emotionOverride: reaction.emotion,
bodyAnimation: reaction.bodyAnimation,
sparkles: reaction.sparkles,
bubbles: reaction.bubbles,
hearts: reaction.hearts,
isActive: true,
}
: IDLE_STATE;
return { state, trigger };
}
+13 -9
View File
@@ -267,16 +267,20 @@ export function useStatusReaction({
// ── Determine final output ──
const isOverrideActive = actionOverride !== null && actionOverride !== undefined;
let finalRecipe: BlobbiVisualRecipe;
let finalLabel: string;
// Memoize the override recipe so the same actionOverride value produces
// a stable object reference. Without this, every parent re-render during
// food-drag creates a new recipe object, which propagates through the
// unmemoized visual tree and forces unnecessary JSON.stringify calls in
// useRecipeFingerprint.
const overrideRecipe = useMemo(
() => (isOverrideActive ? resolveVisualRecipe(actionOverride) : EMPTY_RECIPE),
// actionOverride is a string enum — when it doesn't change, the memo
// returns the same object reference.
[isOverrideActive, actionOverride],
);
if (isOverrideActive) {
finalRecipe = resolveVisualRecipe(actionOverride);
finalLabel = actionOverride;
} else {
finalRecipe = currentResult.recipe;
finalLabel = currentResult.label;
}
const finalRecipe = isOverrideActive ? overrideRecipe : currentResult.recipe;
const finalLabel = isOverrideActive ? actionOverride : currentResult.label;
const isStatusReactionActive = currentResult.label !== 'neutral' && !isOverrideActive;
+4 -1
View File
@@ -25,7 +25,10 @@ export type BlobbiEmotion =
| 'excitedB'
| 'mischievous'
| 'adoring'
| 'hungry';
| 'hungry'
| 'blissful'
| 'eating'
| 'chewing';
/**
* Blobbi variant for variant-specific adjustments.
+1 -1
View File
@@ -92,7 +92,7 @@ export function applyEmotion(
*/
export function emotionAffectsEyes(emotion: BlobbiEmotion): boolean {
const recipe = resolveVisualRecipe(emotion);
return !!(recipe.eyes?.wateryEyes || recipe.eyes?.starEyes || recipe.eyes?.dizzySpirals);
return !!(recipe.eyes?.wateryEyes || recipe.eyes?.starEyes || recipe.eyes?.dizzySpirals || recipe.eyes?.happyArc);
}
// ─── Legacy Re-exports ────────────────────────────────────────────────────────
+47 -19
View File
@@ -28,6 +28,8 @@ import {
insertOverlay,
animateClipPathBlink,
} from './injection';
import { detectBodyPath } from '../bodyEffects/generators';
import type { BodyPathInfo } from '../bodyEffects/types';
// ─── Sad Eyes Effect ──────────────────────────────────────────────────────────
@@ -168,8 +170,14 @@ export function applyStarEyes(
svgText = injectIntoEyeTrackLayer(svgText, eye.side, starElement);
}
// Add sparkles around the Blobbi
const sparkles = generateSparkles(config.color);
// Detect body geometry for precise sparkle placement.
// Uses the same detectBodyPath system as body effects (dirt, stink),
// which reads actual body bounds from data-blobbi-body markers or
// gradient/comment fallback — no hardcoded viewBox assumptions.
const bodyPath = detectBodyPath(svgText);
// Add sparkles distributed around the detected body bounds
const sparkles = generateSparkles(config.color, bodyPath);
svgText = insertOverlay(svgText, `
<!-- Excited sparkles around Blobbi -->
<g class="blobbi-sparkles-group">
@@ -231,25 +239,45 @@ function generateStarElement(eye: EyePosition, config: StarEyeConfig): string {
}
/**
* Generate sparkle elements around the Blobbi.
* Generate sparkle elements distributed around the Blobbi body.
*
* Uses detected body bounds (from detectBodyPath) to place sparkles in an
* elliptical ring around the actual body silhouette with a small margin.
* This works correctly for any viewBox dimension (baby 100x100, adult 200x200,
* or any other) without hardcoded scale assumptions.
*
* Falls back to a centered distribution if body detection fails.
*/
function generateSparkles(color: string): string {
const sparklePositions = [
{ x: 30, y: 8, size: 2.5, delay: 0 },
{ x: 50, y: 5, size: 3, delay: 0.8 },
{ x: 70, y: 8, size: 2, delay: 1.6 },
{ x: 15, y: 25, size: 2.5, delay: 0.4 },
{ x: 85, y: 25, size: 2.5, delay: 1.2 },
{ x: 10, y: 50, size: 2, delay: 0.6 },
{ x: 90, y: 50, size: 2.5, delay: 1.4 },
{ x: 15, y: 75, size: 2, delay: 1.0 },
{ x: 85, y: 75, size: 2, delay: 0.2 },
{ x: 25, y: 90, size: 2.5, delay: 1.8 },
{ x: 75, y: 90, size: 2, delay: 0.5 },
function generateSparkles(color: string, bodyPath: BodyPathInfo | null): string {
// If body detection succeeded, distribute sparkles around the body bounds.
// Otherwise fall back to generic center-based placement.
const cx = bodyPath?.centerX ?? 50;
const cy = bodyPath ? (bodyPath.minY + bodyPath.height / 2) : 50;
const radiusX = bodyPath ? (bodyPath.width / 2) * 1.6 : 40;
const radiusY = bodyPath ? (bodyPath.height / 2) * 1.5 : 42;
const sparkleSize = bodyPath ? Math.max(2, bodyPath.width * 0.04) : 2.5;
// Distribute sparkles at fixed angles around an ellipse surrounding the body
const sparkleAngles = [
{ angle: -90, sizeMul: 1.0, delay: 0 }, // top center
{ angle: -45, sizeMul: 0.8, delay: 0.2 }, // top-right
{ angle: -135, sizeMul: 0.85, delay: 0.4 }, // top-left
{ angle: 0, sizeMul: 0.9, delay: 0.15 }, // right
{ angle: 180, sizeMul: 0.75, delay: 0.5 }, // left
{ angle: 30, sizeMul: 0.7, delay: 0.35 }, // lower-right
{ angle: 150, sizeMul: 0.65, delay: 0.45 }, // lower-left
{ angle: 60, sizeMul: 0.6, delay: 0.1 }, // mid-right
{ angle: 120, sizeMul: 0.7, delay: 0.25 }, // mid-left
{ angle: -60, sizeMul: 0.9, delay: 0.8 }, // upper-right
{ angle: -120, sizeMul: 0.8, delay: 0.6 }, // upper-left
];
return sparklePositions
.map(({ x, y, size, delay }) => {
return sparkleAngles
.map(({ angle, sizeMul, delay }) => {
const rad = (angle * Math.PI) / 180;
const x = cx + Math.cos(rad) * radiusX;
const y = cy + Math.sin(rad) * radiusY;
const size = sparkleSize * sizeMul;
const duration = 2 + delay * 0.3;
return createSparkleElement(x, y, size, color, delay, duration);
})
@@ -528,6 +556,6 @@ function generateSleepyZzz(): string {
* Check if an emotion type affects eyes.
*/
export function emotionAffectsEyes(emotion: string): boolean {
const eyeAffectingEmotions = ['sad', 'excited', 'excitedB', 'dizzy', 'adoring', 'hungry'];
const eyeAffectingEmotions = ['sad', 'excited', 'excitedB', 'dizzy', 'adoring', 'hungry', 'blissful'];
return eyeAffectingEmotions.includes(emotion);
}
+3 -3
View File
@@ -267,9 +267,9 @@ export function replaceCurrentMouth(svgText: string, newMouthSvg: string): strin
});
}
// Also match blobbi-mouth elements with children (non-self-closing, e.g. animated paths)
// Pattern: <path class="...blobbi-mouth..." ...>...</path>
const openCloseMouthRegex = /<path[^>]*class="[^"]*blobbi-mouth[^"]*"[^>]*>[\s\S]*?<\/path>/g;
// Also match blobbi-mouth elements with children (non-self-closing, e.g. animated paths/ellipses)
// Pattern: <path|ellipse class="...blobbi-mouth..." ...>...</path|ellipse>
const openCloseMouthRegex = /<(path|ellipse)[^>]*class="[^"]*blobbi-mouth[^"]*"[^>]*>[\s\S]*?<\/\1>/g;
const openCloseMatches = svgText.match(openCloseMouthRegex);
if (openCloseMatches && openCloseMatches.length > 0) {
let replaced = false;
+111
View File
@@ -411,6 +411,117 @@ export function generateSleepyMouth(centerX: number, centerY: number): string {
</ellipse>`;
}
// ─── Action-Mouth Geometry (shared by eating + chewing) ───────────────────────
/**
* Geometry returned by {@link computeActionMouthGeometry}.
*/
export interface ActionMouthGeometry {
cx: number;
cy: number;
rx: number;
ryEating: number;
ryChewOpen: number;
ryChewClosed: number;
}
/**
* Compute a variant-aware action-mouth geometry from the detected neutral
* mouth. Both the eating and chewing generators use this so they share
* the same anchor and compatible sizes.
*
* **cy** is placed at 55 % of the way from the baseline (lip endpoints)
* to the Bézier control point. For a quadratic curve this lands very
* close to the visual midpoint of the arc — much more accurate than
* using `controlY` directly, which overshoots downward on deep smiles.
*
* **rx** scales modestly from the detected mouth width (×0.18, clamped
* 49) so wide mouths (Froggi) get a proportional but not oversized
* ellipse, and narrow mouths stay at 4 px (same as babies).
*/
export function computeActionMouthGeometry(mouth: MouthPosition): ActionMouthGeometry {
const cx = (mouth.startX + mouth.endX) / 2;
const baselineY = (mouth.startY + mouth.endY) / 2;
const curveDepth = mouth.controlY - baselineY;
const cy = baselineY + curveDepth * 0.55;
const halfWidth = Math.abs(mouth.endX - mouth.startX) / 2;
const rx = Math.min(9, Math.max(4, halfWidth * 0.18));
return {
cx,
cy,
rx,
ryEating: rx * 1.2,
ryChewOpen: rx * 1.0,
ryChewClosed: Math.max(1, rx * 0.2),
};
}
// ─── Eating Mouth ─────────────────────────────────────────────────────────────
/**
* Generate a static "open mouth" ellipse for the eating state (food near
* Blobbi's mouth during drag). Uses the shared action-mouth geometry so
* size and position are consistent with the chewing mouth that follows.
*
* Emits `data-blobbi-mouth="1"` for DOM-based crumb positioning.
*/
export function generateEatingMouth(mouth: MouthPosition): string {
const g = computeActionMouthGeometry(mouth);
return `<ellipse
class="blobbi-mouth blobbi-mouth-eating"
data-blobbi-mouth="1"
cx="${g.cx}" cy="${g.cy}"
rx="${g.rx}" ry="${g.ryEating}"
fill="#1f2937"
/>`;
}
// ─── Chewing Mouth ────────────────────────────────────────────────────────────
/**
* Duration of one chewing chomp cycle in seconds.
* Used by both the SMIL mouth animation and the synchronized feeding sound.
* ~300ms per cycle → fast enough to look like chewing.
*/
export const CHEW_CYCLE_SEC = 0.3;
/**
* Generate a chewing/chomping mouth SVG.
*
* Uses SMIL animation on the vertical radius (`ry`) to cycle between
* an open mouth and a nearly-closed mouth, producing a rhythmic chomping
* effect. The animation runs indefinitely (capped by the emotion timeout
* in the React layer).
*
* Shares anchor and sizing with {@link generateEatingMouth} via
* {@link computeActionMouthGeometry} so the eating → chewing transition
* feels natural (same position, slightly smaller vertical radius).
*
* Emits `data-blobbi-mouth="1"` for DOM-based crumb positioning.
*
* @param mouth - Detected mouth position from the neutral SVG
*/
export function generateChewingMouth(mouth: MouthPosition): string {
const g = computeActionMouthGeometry(mouth);
const dur = CHEW_CYCLE_SEC;
return `<ellipse
class="blobbi-mouth blobbi-mouth-chewing"
data-blobbi-mouth="1"
cx="${g.cx}" cy="${g.cy}"
rx="${g.rx}" ry="${g.ryChewOpen}"
fill="#1f2937"
>
<animate attributeName="ry" values="${g.ryChewOpen};${g.ryChewClosed};${g.ryChewOpen}" dur="${dur}s" repeatCount="indefinite" calcMode="spline" keySplines="0.4 0 0.6 1;0.4 0 0.6 1" />
</ellipse>`;
}
// ─── Sleepy Mouth ─────────────────────────────────────────────────────────────
/**
* Apply the canonical sleepy mouth to a Blobbi SVG.
*
+7
View File
@@ -43,6 +43,8 @@ export {
generateSmallSmile,
generateDroopyMouth,
generateBigSmile,
generateEatingMouth,
generateChewingMouth,
generateDrool,
generateFoodIcon,
generateSleepyMouth,
@@ -51,4 +53,9 @@ export {
computeDroolAnchor,
generateDroolAtAnchor,
type DroolAnchor,
// Action-mouth geometry (shared by eating + chewing)
computeActionMouthGeometry,
type ActionMouthGeometry,
// Chewing timing (shared with feeding sound)
CHEW_CYCLE_SEC,
} from './generators';
+259
View File
@@ -0,0 +1,259 @@
/**
* Cute synchronized "nom nom nom" sound for Blobbi feeding feedback.
*
* The audio rhythm is locked to the chewing mouth SMIL animation
* ({@link CHEW_CYCLE_SEC} from mouth/generators.ts). Each 300 ms visual
* chomp cycle gets two sound layers:
*
* 1. **Open pop** (t = 0 of cycle) — mouth opens → short bright "nom"
* 2. **Close smack** (t ≈ 140 ms) — mouth closes → softer lower "mm"
*
* Four cycles play over the 1 200 ms chewing window, with progressive
* volume decay so later chews feel like echoes of the first bite.
*
* No audio files, no fetch, no dependencies.
*/
import { CHEW_CYCLE_SEC } from '@/blobbi/ui/lib/mouth';
// ─── Tuning constants ─────────────────────────────────────────────────────────
const MASTER_GAIN = 0.38;
/**
* Number of chomp cycles to play.
* 4 × 300 ms = 1 200 ms, matching the visual CHEW_DURATION_MS.
*/
const CHEW_CYCLES = 4;
/**
* Per-cycle gain multipliers. First nom is strongest; later noms decay
* so the sound feels like one chewing sequence, not separate beeps.
*/
const CYCLE_GAINS: readonly number[] = [1.0, 0.7, 0.52, 0.38];
// ─── Open pop: bright short "nom" when the mouth opens ────────────────────────
const OPEN_POP = {
/** Offset within the cycle (ms) — 0 = mouth just opened. */
offsetMs: 0,
durationMs: 72,
startHz: 520,
endHz: 370,
gain: 0.13,
};
// ─── Close smack: softer lower "mm" when the mouth closes ─────────────────────
const CLOSE_SMACK = {
/** Offset within the cycle (ms) — ~half the cycle, mouth is closing. */
offsetMs: 140,
durationMs: 55,
startHz: 400,
endHz: 310,
gain: 0.065,
};
// ─── Hum layer: tiny "nyum~" bridging the open and close ──────────────────────
const HUM = {
offsetMs: 30,
durationMs: 130,
startHz: 740,
midHz: 840,
endHz: 680,
gain: 0.055,
};
/** Total sound duration in seconds (last cycle start + one full cycle + tail). */
const TOTAL_DURATION_SEC =
(CHEW_CYCLES - 1) * CHEW_CYCLE_SEC + CHEW_CYCLE_SEC + 0.04;
// ─── AudioContext singleton ───────────────────────────────────────────────────
let ctx: AudioContext | null = null;
function getContext(): AudioContext | null {
if (ctx) return ctx;
try {
ctx = new AudioContext();
} catch {
ctx = null;
}
return ctx;
}
// ─── Schedulers ───────────────────────────────────────────────────────────────
/** Schedule a short sine pop (open or close). */
function schedulePop(
ac: AudioContext,
destination: AudioNode,
cycleStart: number,
pop: {
offsetMs: number;
durationMs: number;
startHz: number;
endHz: number;
gain: number;
},
cycleGain: number,
): AudioNode[] {
const start = cycleStart + pop.offsetMs / 1000;
const end = start + pop.durationMs / 1000;
const attack = 0.006;
const releaseStart = start + (pop.durationMs / 1000) * 0.35;
const osc = ac.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(pop.startHz, start);
osc.frequency.exponentialRampToValueAtTime(pop.endHz, end);
const gain = ac.createGain();
gain.gain.setValueAtTime(0.0001, start);
gain.gain.linearRampToValueAtTime(pop.gain * cycleGain, start + attack);
gain.gain.setValueAtTime(pop.gain * cycleGain, releaseStart);
gain.gain.exponentialRampToValueAtTime(0.0001, end);
osc.connect(gain);
gain.connect(destination);
osc.start(start);
osc.stop(end);
return [osc, gain];
}
/** Schedule the short triangle "mmm~" hum that bridges open→close. */
function scheduleHum(
ac: AudioContext,
destination: AudioNode,
cycleStart: number,
cycleGain: number,
): AudioNode[] {
const start = cycleStart + HUM.offsetMs / 1000;
const end = start + HUM.durationMs / 1000;
const mid = start + (HUM.durationMs / 1000) * 0.42;
const osc = ac.createOscillator();
osc.type = 'triangle';
osc.frequency.setValueAtTime(HUM.startHz, start);
osc.frequency.exponentialRampToValueAtTime(HUM.midHz, mid);
osc.frequency.exponentialRampToValueAtTime(HUM.endHz, end);
const gain = ac.createGain();
gain.gain.setValueAtTime(0.0001, start);
gain.gain.linearRampToValueAtTime(HUM.gain * cycleGain, start + 0.02);
gain.gain.setValueAtTime(HUM.gain * cycleGain, start + 0.07);
gain.gain.exponentialRampToValueAtTime(0.0001, end);
osc.connect(gain);
gain.connect(destination);
osc.start(start);
osc.stop(end);
return [osc, gain];
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Play the feeding sound synchronized to the chewing mouth animation.
*
* Schedules {@link CHEW_CYCLES} audio cycles, each aligned to one SMIL
* chomp period ({@link CHEW_CYCLE_SEC}). Per cycle:
* - t = 0 ms → open pop (mouth opens)
* - t = 30 ms → hum layer (bridging "nyum~")
* - t = 140 ms → close smack (mouth closes)
*
* Fire-and-forget. Errors are swallowed so feeding is never blocked.
*/
export function playMunchSound(): void {
try {
const ac = getContext();
if (!ac) return;
if (ac.state === 'suspended') {
void ac.resume().catch(() => {});
}
const now = ac.currentTime;
// ── Signal chain: sources → lowpass → dry/wet → master → output ──
const lowpass = ac.createBiquadFilter();
lowpass.type = 'lowpass';
lowpass.frequency.setValueAtTime(1900, now);
lowpass.Q.setValueAtTime(0.55, now);
// Micro-delay for stereo-ish depth.
const delay = ac.createDelay();
delay.delayTime.setValueAtTime(0.018, now);
const dryGain = ac.createGain();
dryGain.gain.setValueAtTime(0.88, now);
const wetGain = ac.createGain();
wetGain.gain.setValueAtTime(0.12, now);
const master = ac.createGain();
master.gain.setValueAtTime(MASTER_GAIN, now);
master.gain.setValueAtTime(MASTER_GAIN, now + TOTAL_DURATION_SEC * 0.82);
master.gain.exponentialRampToValueAtTime(0.0001, now + TOTAL_DURATION_SEC);
lowpass.connect(dryGain);
lowpass.connect(delay);
delay.connect(wetGain);
dryGain.connect(master);
wetGain.connect(master);
master.connect(ac.destination);
const nodes: AudioNode[] = [lowpass, delay, dryGain, wetGain, master];
let lastOsc: OscillatorNode | null = null;
// ── Schedule audio per chew cycle, aligned to SMIL timing ──
//
// The first open-pop fires at exactly `now` (i=0, OPEN_POP.offsetMs=0)
// so the "nom" is heard the instant the chewing mouth appears.
//
// If the sound still feels late after testing, the fix is to call
// playMunchSound() earlier in BlobbiPage (before setActionOverrideEmotion),
// not to add a positive delay here.
for (let i = 0; i < CHEW_CYCLES; i++) {
const cycleStart = now + i * CHEW_CYCLE_SEC;
const cg = CYCLE_GAINS[i] ?? CYCLE_GAINS[CYCLE_GAINS.length - 1];
// Open pop — mouth opens at cycle start
const openNodes = schedulePop(ac, lowpass, cycleStart, OPEN_POP, cg);
nodes.push(...openNodes);
if (openNodes[0] instanceof OscillatorNode) lastOsc = openNodes[0];
// Hum bridge — "nyum~" between open and close
const humNodes = scheduleHum(ac, lowpass, cycleStart, cg);
nodes.push(...humNodes);
if (humNodes[0] instanceof OscillatorNode) lastOsc = humNodes[0];
// Close smack — mouth closes at ~half cycle
const closeNodes = schedulePop(ac, lowpass, cycleStart, CLOSE_SMACK, cg);
nodes.push(...closeNodes);
if (closeNodes[0] instanceof OscillatorNode) lastOsc = closeNodes[0];
}
// ── Cleanup all nodes when the last oscillator ends ──
if (lastOsc) {
lastOsc.onended = () => {
try {
for (const node of nodes) {
node.disconnect();
}
} catch {
// Already disconnected.
}
};
}
} catch {
// Never let audio errors affect feeding.
}
}
+89 -1
View File
@@ -56,6 +56,8 @@ import {
generateSmallSmile,
generateDroopyMouth,
generateBigSmile,
generateEatingMouth,
generateChewingMouth,
generateFoodIcon,
applySleepyMouth,
computeDroolAnchor,
@@ -104,6 +106,8 @@ export interface EyeRecipe {
sleepyBlink?: { cycleDuration: number };
/** Sleeping state: eyes permanently closed, no blink cycle */
sleepingClosed?: true;
/** Happy closed eyes: upward-curving arcs (^_^ squint). No Zzz or mouth change. */
happyArc?: true;
}
/**
@@ -121,6 +125,10 @@ export interface MouthRecipe {
bigSmile?: BigSmileConfig;
/** Droopy/weak mouth */
droopyMouth?: DroopyMouthConfig;
/** Eating open mouth (variant-aware via computeActionMouthGeometry) */
eatingMouth?: true;
/** Chewing/chomping animated mouth */
chewingMouth?: true;
/** Sleepy breathing mouth (canonical replacement) */
sleepyMouth?: true;
}
@@ -271,6 +279,14 @@ export const EMOTION_RECIPES: Record<BlobbiEmotion, BlobbiVisualRecipe> = {
// Used as override during positive actions.
happy: {},
// ── Blissful ────────────────────────────────────────────────────────────────
// Happy closed eyes (^_^ squint) + big smile. Used during feed interaction
// reaction for a visibly content/satisfied look.
blissful: {
eyes: { happyArc: true },
mouth: { bigSmile: { widthScale: 1.1, curveScale: 1.2 } },
},
// ── Angry ───────────────────────────────────────────────────────────────────
// Frustrated, upset. Intense frown, sharp angled brows, flushed body.
angry: {
@@ -333,7 +349,7 @@ export const EMOTION_RECIPES: Record<BlobbiEmotion, BlobbiVisualRecipe> = {
// Used during play and joyful activities.
excited: {
eyes: { starEyes: { points: 5, color: '#fbbf24', scale: 0.9 } },
mouth: { bigSmile: { widthScale: 1.3, curveScale: 1.4 } },
mouth: { bigSmile: { widthScale: 1.15, curveScale: 1.2 } },
},
// ── ExcitedB ────────────────────────────────────────────────────────────────
@@ -360,6 +376,22 @@ export const EMOTION_RECIPES: Record<BlobbiEmotion, BlobbiVisualRecipe> = {
mouth: { roundMouth: { rx: 2.5, ry: 3, filled: true } },
},
// ── Eating ──────────────────────────────────────────────────────────────────
// Mouth wide open, ready to receive food. Used during food-drag when the
// dragged item is near Blobbi's mouth. Simple filled round mouth — no
// extra eye or brow effects so it layers cleanly over the current status.
eating: {
mouth: { eatingMouth: true },
},
// ── Chewing ─────────────────────────────────────────────────────────────────
// Animated chomping mouth shown briefly after food is successfully fed.
// Uses SMIL animation to oscillate the mouth open/closed. No extra eye
// or brow effects so it layers over the current status like eating does.
chewing: {
mouth: { chewingMouth: true },
},
// ── Hungry ──────────────────────────────────────────────────────────────────
// Pleading, needy, hopeful for food. Shiny hopeful eyes (not sad-watery),
// soft smile (not round "O" mouth), pleading brows, drool + food icon.
@@ -664,6 +696,52 @@ function generateSleepingZzz(): string {
</g>`;
}
/**
* Apply happy arc eyes (^_^ squint): upward-curving arcs over closed eyes.
*
* Similar to sleeping closed eyes but with **upward**-curving arcs (happy
* squint shape) and no Zzz, no mouth replacement, no sleeping class.
* Used during the feed interaction reaction for a content/blissful look.
*/
function applyHappyArcEyes(svgText: string, eyes: EyePosition[]): string {
// Close eyes permanently by moving clip-path rects to fully closed position
const clipRectRegex = new RegExp(
`<rect\\s+class="${EYE_CLASSES.clipRect}"\\s+x="([^"]+)"\\s+y="([^"]+)"\\s+width="([^"]+)"\\s+height="([^"]+)"\\s*/>`,
'g'
);
svgText = svgText.replace(clipRectRegex, (_match, x, y, width, height) => {
const baseY = parseFloat(y);
const fullHeight = parseFloat(height);
const closedOffset = fullHeight * 0.95;
const closedY = baseY + closedOffset;
const closedHeight = fullHeight - closedOffset;
// Include a dummy SMIL <animate> element so the JS blink loop in
// useBlobbiEyes detects it and skips overriding the closed position.
// Without this, the rAF blink loop resets clip-rects to the open state.
return `<rect class="${EYE_CLASSES.clipRect}" x="${x}" y="${closedY}" width="${width}" height="${closedHeight}"><animate attributeName="y" values="${closedY}" dur="0.001s" fill="freeze" /></rect>`;
});
// Draw UPWARD-curving arcs (^_^ shape)
// Control point is ABOVE baseline (lineY - curveDepth) to curve upward
const arcLines = eyes.map(eye => {
const lineWidth = eye.radius * 1.6;
const startX = eye.cx - lineWidth / 2;
const endX = eye.cx + lineWidth / 2;
const curveDepth = eye.radius * 0.5;
const yOffset = eye.radius * 0.75;
const lineY = eye.cy + yOffset;
return `<path class="blobbi-happy-eye blobbi-happy-eye-${eye.side}" d="M ${startX} ${lineY} Q ${eye.cx} ${lineY - curveDepth} ${endX} ${lineY}" stroke="#111827" stroke-width="2.5" stroke-linecap="round" fill="none" opacity="1" />`;
}).join('\n');
const overlays = `
<g class="blobbi-happy-arc-overlays">
${arcLines}
</g>`;
svgText = svgText.replace('</svg>', overlays + '\n</svg>');
return svgText;
}
/**
* Apply sleeping state visuals: permanently closed eyes + Zzz.
*
@@ -846,6 +924,10 @@ export function applyVisualRecipe(
svgText = replaceMouthSection(svgText, generateBigSmile(mouth.position, recipe.mouth.bigSmile));
} else if (recipe.mouth.droopyMouth) {
svgText = replaceMouthSection(svgText, generateDroopyMouth(mouth.position, recipe.mouth.droopyMouth));
} else if (recipe.mouth.eatingMouth) {
svgText = replaceMouthSection(svgText, generateEatingMouth(mouth.position));
} else if (recipe.mouth.chewingMouth) {
svgText = replaceMouthSection(svgText, generateChewingMouth(mouth.position));
}
// Note: sleepyMouth is handled in the sleepy animation section below
}
@@ -895,6 +977,12 @@ export function applyVisualRecipe(
svgText = applySleepingClosedEyes(svgText, eyes, mouthAnchor);
}
// ── Happy arc eyes (^_^ squint) ──
// Mutually exclusive with sleeping/sleepy — only fires when those are absent.
if (recipe.eyes?.happyArc && !recipe.eyes?.sleepyBlink && !recipe.eyes?.sleepingClosed) {
svgText = applyHappyArcEyes(svgText, eyes);
}
// ── Animated eyebrows ──
if (recipe.eyebrows?.animated?.enabled) {
svgText = applyAnimatedEyebrows(svgText, recipe.eyebrows.animated);
+330
View File
@@ -0,0 +1,330 @@
/**
* BlobbiSocialActions — Popover-based social interaction button for a Blobbi.
*
* Renders an inline action-bar button (HandHeart icon) that opens a compact
* popover with a two-step flow:
* 1. Action pills: feed, play, clean, medicate
* 2. Item carousel: horizontal carousel for the selected action
*
* Clicking an item publishes a kind 1124 interaction event, shows a brief
* "Sent!" confirmation, then returns to the same carousel with the last-used
* item still focused — allowing rapid repeated interactions without re-navigating.
*
* The popover only closes when the user explicitly dismisses it (click outside,
* close button, or navigation away).
*
* No kind 31124 mutation is ever performed from this surface.
* Requires a logged-in user — renders nothing when logged out.
*/
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { HandHeart } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
import {
buildInteractionEventTemplate,
type InteractionAction,
} from '@/blobbi/core/lib/blobbi-interaction';
import { useBlobbiInteractions } from '@/blobbi/core/hooks/useBlobbiInteractions';
import {
ACTION_METADATA,
ACTION_TO_ITEM_TYPE,
SHELL_REPAIR_KIT_ID,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
hasHappinessEffectForEgg,
type InventoryAction,
} from '@/blobbi/actions/lib/blobbi-action-utils';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { ItemCarousel, type CarouselEntry } from '@/blobbi/rooms/components/ItemCarousel';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Default source tag value when the component is used on the Blobbi detail/naddr page. */
const DEFAULT_SOURCE = 'blobbi-view';
/**
* Supported social actions on the view page.
* Maps InventoryAction to the 1124 InteractionAction name.
*/
const SOCIAL_ACTIONS: { inventory: InventoryAction; interaction: InteractionAction }[] = [
{ inventory: 'feed', interaction: 'feed' },
{ inventory: 'play', interaction: 'play' },
{ inventory: 'clean', interaction: 'clean' },
{ inventory: 'medicine', interaction: 'medicate' },
];
/** Delay (ms) the "Sent!" state is visible before returning to the carousel. */
const SUCCESS_DISPLAY_DELAY = 1200;
// ─── Panel States ─────────────────────────────────────────────────────────────
type PanelStep =
| { step: 'actions' }
| { step: 'carousel'; action: InventoryAction }
| { step: 'pending'; action: InventoryAction; itemId: string }
| { step: 'success'; action: InventoryAction };
const INITIAL_STEP: PanelStep = { step: 'actions' };
// ─── Component ────────────────────────────────────────────────────────────────
interface BlobbiSocialActionsProps {
/** The kind 31124 event of the viewed Blobbi. */
event: NostrEvent;
/**
* Source tag for the kind 1124 event. Convention: `'blobbi-view'` (detail page),
* `'blobbi-feed'` (feed card). Defaults to `'blobbi-view'`.
*/
source?: string;
/**
* Callback fired after a social interaction is successfully published.
* Receives the inventory action that was performed (feed, play, clean, medicine).
*/
onInteractionSuccess?: (action: InventoryAction) => void;
/**
* Pre-parsed companion — avoids redundant `parseBlobbiEvent` when the
* parent already parsed the event for gating purposes.
*/
companion?: ReturnType<typeof parseBlobbiEvent>;
/** Extra classes on the trigger button. */
className?: string;
}
export function BlobbiSocialActions({ event, source = DEFAULT_SOURCE, onInteractionSuccess, companion: companionProp, className }: BlobbiSocialActionsProps) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const parsedCompanion = useMemo(() => companionProp !== undefined ? companionProp : parseBlobbiEvent(event), [event, companionProp]);
const companion = parsedCompanion;
// Pending interaction count since last checkpoint.
// useBlobbiInteractions already applies the checkpoint `since` filter,
// so interactions.length represents unprocessed interactions.
const { interactions, isLoading: interactionsLoading, isError: interactionsError } = useBlobbiInteractions(companion ?? null);
const pendingCount = (!interactionsLoading && !interactionsError) ? interactions.length : 0;
const [open, setOpen] = useState(false);
const [panel, setPanel] = useState<PanelStep>(INITIAL_STEP);
// Track the last item used so we can restore carousel position after success.
const lastUsedItemRef = useRef<string | null>(null);
// Timer for returning from success state to carousel.
const returnTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (returnTimer.current) clearTimeout(returnTimer.current);
};
}, []);
// Reset panel state when popover closes (user dismissal).
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
setPanel(INITIAL_STEP);
lastUsedItemRef.current = null;
if (returnTimer.current) {
clearTimeout(returnTimer.current);
returnTimer.current = null;
}
}
}, []);
/** After success feedback, return to the carousel for the same action. */
const scheduleReturnToCarousel = useCallback((action: InventoryAction) => {
if (returnTimer.current) clearTimeout(returnTimer.current);
returnTimer.current = setTimeout(() => {
setPanel({ step: 'carousel', action });
}, SUCCESS_DISPLAY_DELAY);
}, []);
// ── Handlers ──
const handleSelectAction = useCallback((action: InventoryAction) => {
setPanel({ step: 'carousel', action });
}, []);
const handleBack = useCallback(() => {
setPanel(INITIAL_STEP);
}, []);
const handleUseItem = useCallback(
async (itemId: string) => {
if (!companion || !user) return;
const currentAction = panel.step === 'carousel' ? panel.action : undefined;
const mapping = SOCIAL_ACTIONS.find((s) => s.inventory === currentAction);
if (!mapping) return;
setPanel({ step: 'pending', action: mapping.inventory, itemId });
lastUsedItemRef.current = itemId;
const template = buildInteractionEventTemplate({
ownerPubkey: companion.event.pubkey,
blobbiDTag: companion.d,
action: mapping.interaction,
source,
itemId,
});
try {
await publishEvent(template);
// Invalidate interaction queries so the projected social status
// and activity history both reflect the just-published event.
const coordinate = `31124:${companion.event.pubkey}:${companion.d}`;
queryClient.invalidateQueries({
queryKey: ['blobbi-interactions', coordinate],
});
queryClient.invalidateQueries({
queryKey: ['blobbi-activity-history', coordinate],
});
setPanel({ step: 'success', action: mapping.inventory });
onInteractionSuccess?.(mapping.inventory);
scheduleReturnToCarousel(mapping.inventory);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
toast({
title: 'Interaction failed',
description: message,
variant: 'destructive',
});
setPanel({ step: 'carousel', action: mapping.inventory });
}
},
[companion, user, panel, publishEvent, queryClient, scheduleReturnToCarousel, source, onInteractionSuccess],
);
// ── Carousel entries ──
const activeAction = (panel.step === 'carousel' || panel.step === 'pending') ? panel.action : undefined;
const carouselEntries = useMemo<CarouselEntry[]>(() => {
if (!activeAction || !companion) return [];
const allowedType = ACTION_TO_ITEM_TYPE[activeAction];
const isEgg = companion.stage === 'egg';
const entries: CarouselEntry[] = [];
for (const shopItem of getLiveShopItems()) {
if (shopItem.type !== allowedType) continue;
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) continue;
if (isEgg) {
if (activeAction === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) continue;
if (activeAction === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) continue;
}
entries.push({ id: shopItem.id, icon: <span>{shopItem.icon}</span>, label: shopItem.name });
}
return entries;
}, [activeAction, companion]);
// ── Guard ──
if (!user || !companion) return null;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'flex items-center gap-1 sm:gap-1.5 p-1.5 sm:p-2 rounded-full transition-colors',
open
? 'text-pink-500 bg-pink-500/10'
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
className,
)}
title="Interact with Blobbi"
onClick={(e) => e.stopPropagation()}
>
<HandHeart className="size-[18px] sm:size-5" />
{pendingCount > 0 && (
<span className="text-sm tabular-nums">{pendingCount}</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2 max-w-[17rem]"
side="top"
align="center"
onClick={(e) => e.stopPropagation()}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{/* ── Action pills ── */}
{panel.step === 'actions' && (
<div className="grid grid-cols-2 gap-1.5">
{SOCIAL_ACTIONS.map(({ inventory }) => {
const meta = ACTION_METADATA[inventory];
return (
<button
type="button"
key={inventory}
onClick={() => handleSelectAction(inventory)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg',
'text-sm font-medium transition-all duration-150',
'bg-muted/50 hover:bg-muted active:scale-95',
)}
>
<span className="text-base leading-none">{meta.icon}</span>
<span>{meta.label}</span>
</button>
);
})}
</div>
)}
{/* ── Item carousel ── */}
{(panel.step === 'carousel' || panel.step === 'pending') && (
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 w-full">
<button
type="button"
onClick={handleBack}
disabled={panel.step === 'pending'}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
&larr; Back
</button>
<span className="text-sm font-medium ml-auto">
{activeAction && ACTION_METADATA[activeAction].icon}{' '}
{activeAction && ACTION_METADATA[activeAction].label}
</span>
</div>
{carouselEntries.length > 0 ? (
<ItemCarousel
items={carouselEntries}
onUse={handleUseItem}
activeItemId={panel.step === 'pending' ? panel.itemId : null}
disabled={panel.step === 'pending'}
initialItemId={lastUsedItemRef.current}
/>
) : (
<p className="text-xs text-muted-foreground text-center py-3">
No items available.
</p>
)}
</div>
)}
{/* ── Success ── */}
{panel.step === 'success' && (
<div className="flex items-center justify-center gap-1.5 py-2">
<span className="text-base">{ACTION_METADATA[panel.action].icon}</span>
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Sent!</span>
</div>
)}
</PopoverContent>
</Popover>
);
}
+51 -12
View File
@@ -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 (
<div className="flex flex-col items-center py-4">
{/* Blobbi visual — reflects current condition */}
<div className="relative">
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
lookMode={lookMode}
recipe={feedRecipe}
recipeLabel={feedRecipeLabel}
className="size-48 sm:size-56"
/>
<div
className={cn(
'relative transition-all duration-500',
reactionActive && interactionReaction?.bodyAnimation,
)}
>
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
lookMode={lookMode}
recipe={hasEmotionOverride ? undefined : feedRecipe}
recipeLabel={hasEmotionOverride ? undefined : feedRecipeLabel}
emotion={hasEmotionOverride ? interactionReaction.emotionOverride ?? undefined : undefined}
className="size-48 sm:size-56"
/>
{/* Interaction reaction overlays — sparkles, bubbles, hearts (not for eggs) */}
{!isEgg && (
<>
<ReactionSparkles active={interactionReaction?.sparkles ?? false} />
<ReactionBubbles active={interactionReaction?.bubbles ?? false} />
<FloatingSocialHearts active={interactionReaction?.hearts ?? false} />
</>
)}
</div>
</div>
{/* Name */}
+57 -38
View File
@@ -35,6 +35,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,
@@ -288,6 +292,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"]);
@@ -341,6 +360,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]);
// Zap button shows for any logged-in user except on their own posts.
// On-chain zaps are always available; Lightning is offered inside the dialog
// when the author has lud06/lud16.
@@ -352,36 +378,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);
};
@@ -434,6 +436,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 &&
@@ -678,7 +686,7 @@ export const NoteCard = memo(function NoteCard({
<ProfileCardContent event={event} />
) : isBlobbiState ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} lookMode="follow-pointer" />
<BlobbiStateCard event={event} lookMode="follow-pointer" interactionReaction={blobbiReactionState} />
</Suspense>
) : isUnknownKind ? (
<UnknownKindContent event={event} />
@@ -763,16 +771,17 @@ export const NoteCard = memo(function NoteCard({
// ── Shared action buttons (used in all layouts) ──
const actionButtons = (
<div className="flex items-center gap-5 mt-3 -ml-2">
<div className={cn("flex items-center mt-3 -ml-2", showBlobbiInteract ? "gap-4 sm:gap-5" : "gap-5")}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
type="button"
className={cn("flex items-center gap-1.5 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors", showBlobbiInteract ? "p-1.5 sm:p-2" : "p-2")}
title="Reply"
onClick={(e) => {
e.stopPropagation();
setReplyOpen(true);
}}
>
<MessageCircle className="size-5" />
<MessageCircle className={showBlobbiInteract ? "size-[18px] sm:size-5" : "size-5"} />
{stats?.replies ? (
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
) : null}
@@ -781,10 +790,11 @@ export const NoteCard = memo(function NoteCard({
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? "text-accent hover:text-accent/80 hover:bg-accent/10" : "text-muted-foreground hover:text-accent hover:bg-accent/10"}`}
type="button"
className={cn(`flex items-center gap-1.5 rounded-full transition-colors ${isReposted ? "text-accent hover:text-accent/80 hover:bg-accent/10" : "text-muted-foreground hover:text-accent hover:bg-accent/10"}`, showBlobbiInteract ? "p-1.5 sm:p-2" : "p-2")}
title={isReposted ? "Undo repost" : "Repost"}
>
<RepostIcon className="size-5" />
<RepostIcon className={showBlobbiInteract ? "size-[18px] sm:size-5" : "size-5"} />
{stats?.reposts || stats?.quotes ? (
<span className="text-sm tabular-nums">
{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}
@@ -801,13 +811,20 @@ export const NoteCard = memo(function NoteCard({
reactionCount={stats?.reactions}
/>
{showBlobbiInteract && (
<Suspense fallback={null}>
<BlobbiSocialActions event={event} source="blobbi-feed" companion={blobbiCompanion} onInteractionSuccess={handleBlobbiInteractionSuccess} />
</Suspense>
)}
{canZapAuthor && (
<ZapDialog target={event}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
type="button"
className={cn("flex items-center gap-1.5 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors", showBlobbiInteract ? "p-1.5 sm:p-2" : "p-2")}
title="Zap"
>
<Zap className="size-5" />
<Zap className={showBlobbiInteract ? "size-[18px] sm:size-5" : "size-5"} />
{stats?.zapAmount ? (
<span className="text-sm tabular-nums">
{formatNumber(stats.zapAmount)}
@@ -818,7 +835,8 @@ export const NoteCard = memo(function NoteCard({
)}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
type="button"
className={cn("rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden", showBlobbiInteract ? "p-1.5 sm:p-2" : "p-2")}
title="Share"
onClick={async (e) => {
e.stopPropagation();
@@ -828,18 +846,19 @@ export const NoteCard = memo(function NoteCard({
if (result === "copied") toast({ title: "Link copied to clipboard" });
}}
>
<Share2 className="size-5" />
<Share2 className={showBlobbiInteract ? "size-[18px] sm:size-5" : "size-5"} />
</button>
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
type="button"
className={cn("rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors", showBlobbiInteract ? "p-1.5 sm:p-2" : "p-2")}
title="More"
onClick={(e) => {
e.stopPropagation();
setMoreMenuOpen(true);
}}
>
<MoreHorizontal className="size-5" />
<MoreHorizontal className={showBlobbiInteract ? "size-[18px] sm:size-5" : "size-5"} />
</button>
</div>
);
+24 -10
View File
@@ -13,6 +13,7 @@ import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useToast } from '@/hooks/useToast';
import { formatNumber } from '@/lib/formatNumber';
import { shareOrCopy } from '@/lib/share';
import { cn } from '@/lib/utils';
interface PostActionBarProps {
event: NostrEvent;
@@ -22,6 +23,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({
@@ -30,6 +35,8 @@ export function PostActionBar({
onReply,
onMore,
className,
extraButtons,
compact,
}: PostActionBarProps) {
const { toast } = useToast();
const { user } = useCurrentUser();
@@ -60,11 +67,12 @@ export function PostActionBar({
<div className={`flex items-center justify-between py-1 border-t border-b border-border${className ? ` ${className}` : ''}`}>
{/* Reply / Comments */}
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
type="button"
className={cn("flex items-center gap-1.5 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors", compact ? "p-1.5 sm:p-2" : "p-2")}
title={replyLabel}
onClick={onReply}
>
<MessageCircle className="size-5" />
<MessageCircle className={compact ? "size-[18px] sm:size-5" : "size-5"} />
{stats?.replies ? (
<span className="text-sm tabular-nums">{formatNumber(stats.replies)}</span>
) : null}
@@ -74,10 +82,11 @@ export function PostActionBar({
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button
className={`flex items-center gap-1.5 p-2 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`}
type="button"
className={cn(`flex items-center gap-1.5 rounded-full transition-colors ${isReposted ? 'text-accent hover:text-accent/80 hover:bg-accent/10' : 'text-muted-foreground hover:text-accent hover:bg-accent/10'}`, compact ? "p-1.5 sm:p-2" : "p-2")}
title={isReposted ? 'Undo repost' : 'Repost'}
>
<RepostIcon className="size-5" />
<RepostIcon className={compact ? "size-[18px] sm:size-5" : "size-5"} />
{repostTotal > 0 ? (
<span className="text-sm tabular-nums">{formatNumber(repostTotal)}</span>
) : null}
@@ -93,14 +102,17 @@ export function PostActionBar({
reactionCount={stats?.reactions}
/>
{extraButtons}
{/* Zap */}
{canZapAuthor && (
<ZapDialog target={event}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
type="button"
className={cn("flex items-center gap-1.5 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors", compact ? "p-1.5 sm:p-2" : "p-2")}
title="Zap"
>
<Zap className="size-5" />
<Zap className={compact ? "size-[18px] sm:size-5" : "size-5"} />
{stats?.zapAmount ? (
<span className="text-sm tabular-nums">{formatNumber(stats.zapAmount)}</span>
) : null}
@@ -110,20 +122,22 @@ export function PostActionBar({
{/* Share */}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden"
type="button"
className={cn("rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors sidebar:hidden", compact ? "p-1.5 sm:p-2" : "p-2")}
title="Share"
onClick={handleShare}
>
<Share2 className="size-5" />
<Share2 className={compact ? "size-[18px] sm:size-5" : "size-5"} />
</button>
{/* More */}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
type="button"
className={cn("rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors", compact ? "p-1.5 sm:p-2" : "p-2")}
title="More"
onClick={onMore}
>
<MoreHorizontal className="size-5" />
<MoreHorizontal className={compact ? "size-[18px] sm:size-5" : "size-5"} />
</button>
</div>
);
+2
View File
@@ -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;
+148
View File
@@ -480,6 +480,154 @@
}
}
/* ─── 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;
}
/* ─── 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;
}
/* ─── Crumb Burst (food-drag chewing feedback) ───────────────────────────────── */
@keyframes crumb-fall {
0% {
opacity: 1;
transform: translate(0, 0) scale(1);
}
60% {
opacity: 0.7;
}
100% {
opacity: 0;
transform: translate(var(--crumb-dx), var(--crumb-dy)) scale(0.3);
}
}
.animate-crumb-fall {
animation: crumb-fall 1100ms ease-out forwards;
}
/* Floating reward word that drifts upward and fades out */
@keyframes reward-pop {
0% {
opacity: 0;
transform: translate(-50%, 0) scale(0.7);
}
15% {
opacity: 1;
transform: translate(-50%, -4px) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -28px) scale(0.9);
}
}
.animate-reward-pop {
animation: reward-pop 1100ms ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-social-heart-float,
.animate-reaction-wiggle,
.animate-reaction-bounce,
.animate-reaction-sparkle,
.animate-reaction-bubble,
.animate-reaction-bubble-backdrop,
.animate-crumb-fall,
.animate-reward-pop {
animation: none !important;
opacity: 0 !important;
}
}
/* ─── Blobbi Eye Animation ───────────────────────────────────────────────────── */
/**
+679 -64
View File
@@ -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';
@@ -22,6 +28,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';
@@ -36,12 +43,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,
@@ -94,6 +104,7 @@ import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/com
import { BlobbiDevEditor, useBlobbiDevUpdate, type BlobbiDevUpdates, BlobbiEmotionPanel, useEffectiveEmotion, isLocalhostDev } from '@/blobbi/dev';
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
import { playMunchSound } from '@/blobbi/ui/lib/munchSound';
import {
BlobbiRoomShell,
BlobbiRoomHero,
@@ -114,7 +125,9 @@ 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 { useFoodDrag, type UseFoodDragReturn } from '@/blobbi/rooms/hooks/useFoodDrag';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
@@ -385,21 +398,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;
@@ -743,9 +748,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 (
<>
@@ -776,38 +781,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 (
<>
<BlobbiSelectorPage
companions={filteredCompanions}
onSelect={handleSelectBlobbi}
isLoading={companionFetching}
onAdopt={() => setShowAdoptionFlow(true)}
currentCompanion={profile?.currentCompanion}
/>
{/* Adoption Flow Modal */}
<Dialog open={showAdoptionFlow} onOpenChange={setShowAdoptionFlow}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto p-0">
<BlobbiOnboardingFlow
profile={profile}
updateProfileEvent={updateProfileEvent}
updateCompanionEvent={updateCompanionEvent}
invalidateProfile={invalidateProfile}
invalidateCompanion={invalidateCompanion}
setStoredSelectedD={setStoredSelectedD}
adoptionOnly={true}
onComplete={() => setShowAdoptionFlow(false)}
/>
</DialogContent>
</Dialog>
</>
);
}
// ─── 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
@@ -870,7 +843,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 ────────────────────────────────────────────────────
@@ -962,6 +935,78 @@ function BlobbiDashboard({
const currentRoom: BlobbiRoomId = isSleeping ? 'rest' : isValidRoomId(storedRoom) ? storedRoom : DEFAULT_INITIAL_ROOM;
const poopStateRef = useRef<PoopState | null>(null);
// ─── 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<GuideTarget | null>(null);
@@ -1021,7 +1066,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
@@ -1060,14 +1107,20 @@ 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
// Temporary action override used by drag-to-feed / chewing flow.
const [actionOverrideEmotion, setActionOverrideEmotion] = useState<BlobbiEmotion | null>(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<BlobbiEmotion | null>(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: action override > 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,
@@ -1076,10 +1129,16 @@ function BlobbiDashboard({
energy: projectedState?.stats.energy ?? companion.stats.energy ?? 100,
}), [projectedState, companion.stats]);
// Combined emotion override: interaction reaction wins over music.
const combinedEmotionOverride =
actionOverrideEmotion ??
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.
@@ -1315,29 +1374,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)
@@ -1403,20 +1462,267 @@ function BlobbiDashboard({
}, 1500);
}, [ensureCanonicalBeforeAction, publishEvent, updateCompanionEvent]);
// Handle using an item from the items tab
// Shared timer ref for temporary action-emotion cleanup.
// Used across the current feeding/item interaction paths so older timers
// do not clear a newer visual state.
const actionCleanupRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Handle tap-based item use.
// Non-food actions use this path from room bars, and the fridge still uses it for food for now.
// 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;
clearTimeout(actionCleanupRef.current);
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);
actionCleanupRef.current = setTimeout(() => setActionOverrideEmotion(null), 1500);
});
};
// ─── Food drag-to-feed ───────────────────────────────────────────────────
//
// Timing constants — tweak these to tune how the feed reward feels.
const CHEW_DURATION_MS = 1200; // chewing animation before → happy
const CRUMB_DURATION_MS = 1200; // how long crumb particles stay visible
const HAPPY_DURATION_MS = 1500; // happy face after chewing
const CRUMB_Y_OFFSET = 4; // px below the mouth center where crumbs spawn
const REWARD_Y_RATIO = 0.08; // fraction of visual height from top for reward text
//
// Visual sequence:
// eating (open mouth) → chewing + crumbs (CHEW_DURATION_MS) → happy (HAPPY_DURATION_MS) → null
// Mutation timing: starts immediately on drop — no delay.
//
// The chewing phase is purely visual. The mutation fires right away so
// Nostr publishing and stat changes are not blocked by the animation.
// If the mutation fails, we skip the happy phase and clear the override.
//
// Race-condition strategy:
// feedSeqRef — monotonically increasing counter. Every timer and promise
// continuation captures the value at invocation and bails
// out if a newer sequence has started.
// mountedRef — set to false on unmount. All continuations check this
// before calling setState.
const [crumbBurst, setCrumbBurst] = useState<{
crumbX: number; crumbY: number; // crumb particle origin (just below the mouth)
rewardX: number; rewardY: number; // reward text anchor (above the head)
} | null>(null);
const feedSeqRef = useRef(0);
const mountedRef = useRef(true);
// Timer refs for chew→happy transition, happy→null cleanup, crumb cleanup,
// and a hard safety timeout that prevents chewing from getting stuck.
const chewTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const happyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const crumbTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const safetyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const clearFeedTimers = useCallback(() => {
clearTimeout(actionCleanupRef.current);
actionCleanupRef.current = undefined;
clearTimeout(chewTimerRef.current);
chewTimerRef.current = undefined;
clearTimeout(happyTimerRef.current);
happyTimerRef.current = undefined;
clearTimeout(crumbTimerRef.current);
crumbTimerRef.current = undefined;
clearTimeout(safetyTimerRef.current);
safetyTimerRef.current = undefined;
}, []);
// Clean up all feed timers and mark unmounted.
useEffect(() => () => {
mountedRef.current = false;
clearFeedTimers();
}, [clearFeedTimers]);
const handleNearMouthChange = useCallback((near: boolean) => {
setActionOverrideEmotion(near ? 'eating' : null);
}, []);
/** Drag-to-feed handler: fires mutation immediately, overlays chewing
* animation for CHEW_DURATION_MS, then transitions to happy if the
* mutation succeeded, or clears the override on failure.
*
* Every async continuation (timer callbacks, .then, .finally) captures
* the current `seq` value and checks `seq === feedSeqRef.current` before
* writing state. If a newer sequence has started (or the component
* unmounted), the continuation is a no-op. */
const handleFeedFromDrag = useCallback((itemId: string) => {
const action = getActionForItem(itemId);
if (!action || isUsingItem) return;
// Cancel any in-flight feed animation timers from a prior sequence.
clearFeedTimers();
// Stamp this sequence so all continuations can verify ownership.
const seq = ++feedSeqRef.current;
/** Guard: returns true only when this sequence is still active and
* the component is mounted. Every continuation calls this before
* touching React state. */
const isActive = () => mountedRef.current && seq === feedSeqRef.current;
// ── Overfeed check (must run before the mutation fires) ──
maybeOverfeedPoop(action, companion.stats.hunger ?? 0, poopStateRef.current);
// ── Lock + visual + audio ──
setUsingItemId(itemId);
setActionOverrideEmotion('chewing');
playMunchSound();
// Spawn crumb particles just below the mouth, and anchor the reward
// text above the head.
//
// The crumb origin is read from the actual chewing-mouth element
// (marked with data-blobbi-mouth) so crumbs align with the real
// mouth regardless of adult variant. Falls back to the visual
// bounding box ratio when the marker is absent (e.g. Owli/beak).
//
// Wrapped in requestAnimationFrame so the DOM query runs *after*
// React has committed the chewing mouth from the state update above.
// Without this, the query would see the previous eating/neutral
// mouth (or no marker at all) because React 18 batches setState.
//
// Reward text: always anchored above the head via the visual rect.
const el = document.querySelector<HTMLElement>('[data-blobbi-visual]');
if (el) {
requestAnimationFrame(() => {
if (!isActive()) return;
const r = el.getBoundingClientRect();
const mouthEl = el.querySelector<SVGElement>('[data-blobbi-mouth]');
let crumbOriginX: number;
let crumbOriginY: number;
if (mouthEl) {
const mr = mouthEl.getBoundingClientRect();
crumbOriginX = mr.left + mr.width / 2;
crumbOriginY = mr.top + mr.height / 2 + CRUMB_Y_OFFSET;
} else {
crumbOriginX = r.left + r.width * 0.5;
crumbOriginY = r.top + r.height * 0.67 + CRUMB_Y_OFFSET;
}
setCrumbBurst({
crumbX: crumbOriginX,
crumbY: crumbOriginY,
rewardX: r.left + r.width * 0.5,
rewardY: r.top + r.height * REWARD_Y_RATIO,
});
crumbTimerRef.current = setTimeout(() => {
if (isActive()) setCrumbBurst(null);
}, CRUMB_DURATION_MS);
});
}
// ── Mutation starts NOW — no delay ──
//
// Two async boundaries must both complete before the post-chew
// transition fires:
// 1. The CHEW_DURATION_MS chewing timer (visual minimum)
// 2. The onUseItem promise (mutation)
//
// `mutationResult` is 'pending' until the promise settles, then
// 'ok' or 'failed'. `chewDone` flips to true when the timer fires.
// Whichever boundary fires second sees both flags set and calls
// `tryTransition()`, which applies the correct emotion once.
let mutationResult: 'pending' | 'ok' | 'failed' = 'pending';
let chewDone = false;
/** Apply the post-chew emotion. Only called when BOTH the chew timer
* has elapsed AND the mutation has settled. Guarded by isActive(). */
const tryTransition = () => {
if (!chewDone || mutationResult === 'pending') return;
if (!isActive()) return;
// Normal flow completed — cancel the safety timeout.
clearTimeout(safetyTimerRef.current);
safetyTimerRef.current = undefined;
if (mutationResult === 'ok') {
setActionOverrideEmotion('happy');
happyTimerRef.current = setTimeout(() => {
if (isActive()) setActionOverrideEmotion(null);
}, HAPPY_DURATION_MS);
} else {
setActionOverrideEmotion(null);
}
};
onUseItem(itemId, action).then(
() => {
mutationResult = 'ok';
if (isActive() && guideTarget?.targetItemId === itemId) {
setGuideTarget(null);
}
},
() => { mutationResult = 'failed'; },
).finally(() => {
if (isActive()) setUsingItemId(null);
tryTransition();
});
// ── After chewing phase, check if mutation also settled ──
chewTimerRef.current = setTimeout(() => {
chewDone = true;
tryTransition();
}, CHEW_DURATION_MS);
// ── Hard safety timeout ──
// If the mutation promise never settles (network hang, relay timeout,
// TanStack Query edge case), chewing would stay on forever. This
// forces a clear after 5 seconds regardless. Cleared by tryTransition
// when the normal flow completes, and by clearFeedTimers on new
// sequence / unmount.
safetyTimerRef.current = setTimeout(() => {
if (isActive()) {
setActionOverrideEmotion(null);
setUsingItemId(null);
}
}, 5000);
}, [isUsingItem, onUseItem, guideTarget, clearFeedTimers, companion.stats.hunger]);
const foodDragHook = useFoodDrag(handleFeedFromDrag, handleNearMouthChange);
return (
<DashboardShell>
@@ -1470,6 +1776,15 @@ function BlobbiDashboard({
onStartEvolution={handleStartEvolution}
/>
)}
{activeDrawer === 'activity' && (
<ActivityTabContent
companion={companion}
socialOpen={companion.socialOpen}
onToggleSocial={handleToggleSocial}
isSocialToggling={isSocialToggling}
isEgg={isEgg}
/>
)}
{activeDrawer === 'more' && (
<MoreTabContent
companion={companion}
@@ -1497,6 +1812,12 @@ function BlobbiDashboard({
<span className="text-sm">Quests</span>
</span>
</TabButton>
<TabButton label="Activity" active={activeDrawer === 'activity'} onClick={() => toggleDrawer('activity')}>
<span className="flex items-center gap-1.5">
<Activity className="size-4" />
<span className="text-sm">Activity</span>
</span>
</TabButton>
<TabButton label="Blobbis" active={activeDrawer === 'more'} onClick={() => toggleDrawer('more')}>
<span className="flex items-center gap-1.5">
<Egg className="size-4" />
@@ -1533,6 +1854,7 @@ function BlobbiDashboard({
effectiveEmotion={effectiveEmotion}
hasDevOverride={hasDevOverride}
blobbiReaction={blobbiReaction}
interactionReaction={isEgg ? undefined : interactionReaction}
isActiveFloatingCompanion={isActiveFloatingCompanion}
isUpdatingCompanion={isUpdatingCompanion}
handleSetAsCompanion={handleSetAsCompanion}
@@ -1597,10 +1919,39 @@ function BlobbiDashboard({
poopStateRef={poopStateRef}
guideHighlightId={guideHighlightId}
guideActionGlow={guideActionGlow}
foodDragHook={foodDragHook}
carouselKeyPrefix={`blobbi:carousel:${user?.pubkey ?? 'anon'}:${companion.d}`}
/>
)}
</BlobbiRoomShell>
{/* ─── Food drag ghost overlay ─── */}
{foodDragHook.drag && (
<div
ref={foodDragHook.ghostRef}
className="fixed pointer-events-none z-[60]"
style={{
left: foodDragHook.drag.startX,
top: foodDragHook.drag.startY,
transform: 'translate(-50%, -50%)',
}}
>
<span className="text-4xl sm:text-5xl drop-shadow-lg transition-transform duration-150">
{foodDragHook.drag.emoji}
</span>
</div>
)}
{/* ─── Crumb burst overlay (chewing feedback) ─── */}
{crumbBurst && (
<CrumbBurst
key={feedSeqRef.current}
crumbX={crumbBurst.crumbX}
crumbY={crumbBurst.crumbY}
rewardX={crumbBurst.rewardX}
rewardY={crumbBurst.rewardY}
/>
)}
{/* ─── Dialogs (only for things that genuinely need modals) ─── */}
@@ -1720,6 +2071,8 @@ interface RoomBottomBarProps {
guideHighlightId?: string | null;
/** Action to glow (guide flow, e.g. 'sleep'). */
guideActionGlow?: string | null;
/** Food drag hook for drag-to-feed in the kitchen. */
foodDragHook?: UseFoodDragReturn;
/** localStorage key prefix for carousel focus persistence (pubkey:blobbiD). */
carouselKeyPrefix: string;
}
@@ -1839,6 +2192,28 @@ const STAT_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
energy: Zap,
};
/**
* Shared overfeed check. Call synchronously at the moment of feeding,
* before the mutation fires, so `hungerBefore` captures the pre-feed value.
*
* Both the tap-to-feed (`handleFeedItem`) and drag-to-feed
* (`handleFeedFromDrag`) paths must call this to keep poop behaviour
* consistent.
*/
function maybeOverfeedPoop(
action: string | null | undefined,
hungerBefore: number,
poopState: PoopState | null,
): void {
if (
action === 'feed' &&
hungerBefore >= OVERFEED_THRESHOLD &&
Math.random() < OVERFEED_CHANCE
) {
poopState?.addPoop('overfeed');
}
}
function KitchenBar({
companion,
currentStats,
@@ -1850,6 +2225,7 @@ function KitchenBar({
poopStateRef,
guideHighlightId,
guideActionGlow,
foodDragHook,
carouselKeyPrefix,
}: RoomBottomBarProps) {
const [storedFocusId, setStoredFocusId] = useLocalStorage<string | null>(`${carouselKeyPrefix}:kitchen`, null);
@@ -1872,16 +2248,27 @@ function KitchenBar({
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
// Feed-with-overfeed: wrap handleUseItemFromTab to trigger poop on overfeed
// Current tap-based feed path.
// Reuses handleUseItemFromTab and injects the overfeed check first.
const handleFeedItem = useCallback((itemId: string) => {
const action = getActionForItem(itemId);
const hungerBeforeFeed = companion.stats.hunger ?? 0;
maybeOverfeedPoop(action, companion.stats.hunger ?? 0, poopState);
handleUseItemFromTab(itemId);
if (action === 'feed' && hungerBeforeFeed >= OVERFEED_THRESHOLD && Math.random() < OVERFEED_CHANCE) {
poopState?.addPoop('overfeed');
}
}, [companion.stats.hunger, handleUseItemFromTab, poopState]);
// Build pointer-down handler for food drag-to-feed.
// After pointerdown, the drag hook owns the lifecycle via global window
// listeners — the carousel button plays no further role.
const centerPointerHandlers = useMemo(() => {
if (!foodDragHook || foodEntries.length === 0 || isDisabled) return undefined;
const { onDragStart: start } = foodDragHook;
return {
onPointerDown: (e: React.PointerEvent, entry: CarouselEntry) => {
const rawItem = foodItems.find(i => i.id === entry.id);
start(e, entry.id, rawItem?.icon ?? '🍽');
},
};
}, [foodDragHook, foodEntries.length, foodItems, isDisabled]);
return (
<>
<InteractivePoopOverlay drag={drag} poopStateRef={poopStateRef} roomId="kitchen" />
@@ -1958,6 +2345,7 @@ function KitchenBar({
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
highlightId={guideHighlightId}
centerPointerHandlers={centerPointerHandlers}
initialItemId={storedFocusId ?? undefined}
onFocusChange={handleFocusChange}
/>
@@ -2598,6 +2986,138 @@ function MoreTabContent({
}
// ─── Activity Tab Content ─────────────────────────────────────────────────────
/** Action label + emoji for display in the activity list */
const INTERACTION_ACTION_DISPLAY: Record<string, { label: string; icon: string }> = {
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<void>;
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 (
<div className="px-4 sm:px-6 space-y-4">
{/* ─── Social Permission Toggle (hidden for eggs) ─── */}
{isEgg ? (
<div className="flex items-center gap-2.5 rounded-lg border border-dashed p-3">
<Egg className="size-4 shrink-0 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Social care settings will unlock after your Blobbi hatches.
</p>
</div>
) : (
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<label htmlFor={socialToggleId} className="flex items-center gap-2.5 cursor-pointer select-none min-w-0">
<Users className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">Allow others to care for this Blobbi</p>
<p className="text-xs text-muted-foreground mt-0.5">
{socialOpen ? 'Anyone can feed, play, and clean.' : 'Only you can interact.'}
</p>
</div>
</label>
<Switch
id={socialToggleId}
checked={socialOpen}
onCheckedChange={onToggleSocial}
disabled={isSocialToggling}
aria-label="Allow other people to care for this Blobbi"
/>
</div>
)}
{/* ─── Recent Caretakers List ─── */}
{isLoading ? (
<div className="space-y-2">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-9 w-full rounded-lg" />
))}
</div>
) : displayInteractions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Activity className="size-6 mb-2 opacity-40" />
<p className="text-sm">No recent activity</p>
</div>
) : (
<>
<p className="text-xs text-muted-foreground">
{allInteractions.length > 20 ? 'Recent caretakers (last 24h)' : 'Recent caretakers'}
</p>
<div className="space-y-1">
{displayInteractions.map((ix) => {
const actionInfo = INTERACTION_ACTION_DISPLAY[ix.action] ?? { label: ix.action, icon: '❓' };
const item = ix.itemId ? getShopItemById(ix.itemId) : undefined;
return (
<div
key={ix.event.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md bg-muted/40 text-sm"
>
<span className="text-base leading-none">{actionInfo.icon}</span>
<span className="font-medium">{actionInfo.label}</span>
{item && (
<span className="text-muted-foreground truncate max-w-[7rem]">
{item.icon} {item.name}
</span>
)}
<span className="ml-auto flex items-center gap-1.5 shrink-0 text-xs text-muted-foreground">
<CaretakerLink pubkey={ix.authorPubkey} />
<span>{timeAgo(ix.createdAt)}</span>
</span>
</div>
);
})}
</div>
</>
)}
</div>
);
}
/** 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 (
<Link
to={profilePath}
className="font-medium text-foreground hover:underline truncate max-w-[6rem]"
title={displayName}
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
);
}
// ─── Blobbi Selector Page ─────────────────────────────────────────────────────
interface BlobbiSelectorPageProps {
@@ -2687,6 +3207,101 @@ function DashboardLoadingState() {
);
}
// ─── Crumb Burst (chewing feedback particles) ────────────────────────────────
/** Reward words — one is picked at random on each feed. */
const REWARD_WORDS = [
'nhom!', 'nom nom!', 'yum!', 'yum yum!', 'mmm~',
'munch!', 'cronch!', 'tasty!', 'hehe!', '\u2661',
] as const;
/**
* Crumb particle configs — 12 small dots that spawn from a compact
* mouth-shaped strip and tumble mostly downward.
*
* `sx`/`sy` — spawn offset from the mouth center (mouth ~16 px wide).
* `dx`/`dy` — drift from the spawn point during the fall animation.
* `delay` — staggered start for a natural feel.
* `size` — 24 px (small crumbs with a few medium ones).
* `color` — warm food-like Tailwind colour class.
*/
const CRUMB_PARTICLES: ReadonlyArray<{
sx: number; sy: number; dx: number; dy: number;
delay: number; size: number; color: string;
}> = [
// left side of mouth
{ sx: -7, sy: 0, dx: -4, dy: 14, delay: 0, size: 2, color: 'bg-amber-600/90' },
{ sx: -5, sy: 1, dx: -2, dy: 18, delay: 50, size: 3, color: 'bg-orange-500/85' },
{ sx: -8, sy: -1, dx: -5, dy: 12, delay: 100, size: 2, color: 'bg-yellow-600/80' },
// center of mouth
{ sx: -2, sy: 2, dx: 1, dy: 20, delay: 30, size: 3, color: 'bg-amber-700/90' },
{ sx: 1, sy: 3, dx: -1, dy: 24, delay: 80, size: 4, color: 'bg-orange-600/85' },
{ sx: 0, sy: 2, dx: 2, dy: 16, delay: 120, size: 2, color: 'bg-amber-500/90' },
// right side of mouth
{ sx: 5, sy: 1, dx: 3, dy: 18, delay: 40, size: 3, color: 'bg-amber-600/80' },
{ sx: 7, sy: 0, dx: 5, dy: 14, delay: 90, size: 2, color: 'bg-yellow-700/80' },
{ sx: 8, sy: -1, dx: 4, dy: 12, delay: 130, size: 2, color: 'bg-orange-500/75' },
// a few extra that fall a bit further for depth
{ sx: -3, sy: 2, dx: -3, dy: 26, delay: 60, size: 4, color: 'bg-amber-700/80' },
{ sx: 3, sy: 2, dx: 2, dy: 28, delay: 110, size: 3, color: 'bg-yellow-600/75' },
{ sx: 0, sy: 3, dx: 0, dy: 22, delay: 140, size: 2, color: 'bg-orange-600/80' },
];
/**
* Burst of crumb particles + a tiny floating reward word.
*
* Crumbs are anchored at (crumbX, crumbY) — just below the mouth — and
* fall outward via the `crumb-fall` CSS animation.
*
* The reward word is anchored at (rewardX, rewardY) — above the head —
* and floats upward via `reward-pop`.
*
* Both layers are pointer-events-none and aria-hidden; purely decorative.
*/
function CrumbBurst({ crumbX, crumbY, rewardX, rewardY }: {
crumbX: number; crumbY: number;
rewardX: number; rewardY: number;
}) {
// Pick a stable random word for this burst instance.
const [word] = useState(() => REWARD_WORDS[Math.floor(Math.random() * REWARD_WORDS.length)]);
return (
<>
{/* Crumb particles — anchored just below the mouth */}
<div
className="fixed pointer-events-none z-[60]"
style={{ left: crumbX, top: crumbY }}
aria-hidden="true"
>
{CRUMB_PARTICLES.map((p, i) => (
<span
key={i}
className={`absolute rounded-full ${p.color} animate-crumb-fall`}
style={{
left: p.sx,
top: p.sy,
width: p.size,
height: p.size,
animationDelay: `${p.delay}ms`,
'--crumb-dx': `${p.dx}px`,
'--crumb-dy': `${p.dy}px`,
} as React.CSSProperties}
/>
))}
</div>
{/* Floating reward word — anchored above the head */}
<span
className="fixed pointer-events-none z-[60] text-xs font-bold text-amber-500 drop-shadow-[0_1px_2px_rgba(180,83,9,0.4)] animate-reward-pop whitespace-nowrap select-none"
style={{ left: rewardX, top: rewardY, transform: 'translate(-50%, 0)' }}
aria-hidden="true"
>
{word}
</span>
</>
);
}
// ─── Hatch Ceremony Overlay ───────────────────────────────────────────────────
+26 -1
View File
@@ -39,6 +39,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";
@@ -184,6 +188,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";
@@ -1311,6 +1316,20 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const [interactionsOpen, setInteractionsOpen] = useState(false);
const [interactionsTab, setInteractionsTab] =
useState<InteractionTab>("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),
@@ -2192,7 +2211,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<EncryptedLetterContent event={event} />
) : isBlobbiState ? (
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
<BlobbiStateCard event={event} lookMode="follow-pointer" />
<BlobbiStateCard event={event} lookMode="follow-pointer" interactionReaction={blobbiReactionState} />
</Suspense>
) : isBadgeAward ? (
<BadgeAwardCard event={event} />
@@ -2240,6 +2259,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="-mx-4 px-4"
compact={showBlobbiInteract}
extraButtons={showBlobbiInteract ? (
<Suspense fallback={null}>
<BlobbiSocialActions event={event} companion={blobbiCompanion} onInteractionSuccess={handleBlobbiInteractionSuccess} />
</Suspense>
) : undefined}
/>
<NoteMoreMenu