diff --git a/NIP.md b/NIP.md index 7c0a1c53..c848c4fb 100644 --- a/NIP.md +++ b/NIP.md @@ -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::"], + ["p", ""], + ["action", "feed"], + ["source", "blobbi-page"], + ["blobbi", ""], + ["item", ""], + ["alt", "Blobbi interaction: feed"] + ] +} +``` + +**Content:** Empty string (`""`). + +**Required tags:** + +| Tag | Description | +|----------|---------------------------------------------------------------------------------| +| `a` | Coordinate of the target Blobbi: `31124::` | +| `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 diff --git a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts index ed8675d1..9e5e18dc 100644 --- a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts +++ b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts @@ -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 => { @@ -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, diff --git a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts index de821221..578a4377 100644 --- a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts +++ b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts @@ -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 => { @@ -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, diff --git a/src/blobbi/companion/hooks/useBlobbiCompanionData.ts b/src/blobbi/companion/hooks/useBlobbiCompanionData.ts index 42a73c1c..77678f5f 100644 --- a/src/blobbi/companion/hooks/useBlobbiCompanionData.ts +++ b/src/blobbi/companion/hooks/useBlobbiCompanionData.ts @@ -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 diff --git a/src/blobbi/companion/interaction/useBlobbiItemUse.ts b/src/blobbi/companion/interaction/useBlobbiItemUse.ts index 82d761fc..0db0b119 100644 --- a/src/blobbi/companion/interaction/useBlobbiItemUse.ts +++ b/src/blobbi/companion/interaction/useBlobbiItemUse.ts @@ -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 }; }, diff --git a/src/blobbi/core/hooks/useBlobbiActivityHistory.ts b/src/blobbi/core/hooks/useBlobbiActivityHistory.ts new file mode 100644 index 00000000..0dbc2a0a --- /dev/null +++ b/src/blobbi/core/hooks/useBlobbiActivityHistory.ts @@ -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, + }; +} diff --git a/src/blobbi/core/hooks/useBlobbiInteractions.ts b/src/blobbi/core/hooks/useBlobbiInteractions.ts new file mode 100644 index 00000000..f639d2a2 --- /dev/null +++ b/src/blobbi/core/hooks/useBlobbiInteractions.ts @@ -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, + }; +} diff --git a/src/blobbi/core/hooks/useCanonicalSync.ts b/src/blobbi/core/hooks/useCanonicalSync.ts new file mode 100644 index 00000000..6bb243bd --- /dev/null +++ b/src/blobbi/core/hooks/useCanonicalSync.ts @@ -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(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]); +} diff --git a/src/blobbi/core/hooks/useConsolidateSocialInteractions.ts b/src/blobbi/core/hooks/useConsolidateSocialInteractions.ts new file mode 100644 index 00000000..16b3c850 --- /dev/null +++ b/src/blobbi/core/hooks/useConsolidateSocialInteractions.ts @@ -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; + /** 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 => { + 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 }; +} diff --git a/src/blobbi/core/hooks/useProjectedBlobbiState.ts b/src/blobbi/core/hooks/useProjectedBlobbiState.ts index d66dd749..76c963c7 100644 --- a/src/blobbi/core/hooks/useProjectedBlobbiState.ts +++ b/src/blobbi/core/hooks/useProjectedBlobbiState.ts @@ -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; } diff --git a/src/blobbi/core/lib/blobbi-interaction.ts b/src/blobbi/core/lib/blobbi-interaction.ts new file mode 100644 index 00000000..4c7724b1 --- /dev/null +++ b/src/blobbi/core/lib/blobbi-interaction.ts @@ -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::"] + * ["p", ""] + * ["action", ""] + * ["source", ""] + * + * Optional tags: + * ["blobbi", ""] + * ["item", ""] + * ["client", ""] — 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 = { + 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::") */ + 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, + 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 = {}; + 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 }); +} diff --git a/src/blobbi/core/lib/blobbi-social-projection.ts b/src/blobbi/core/lib/blobbi-social-projection.ts new file mode 100644 index 00000000..0e0809ad --- /dev/null +++ b/src/blobbi/core/lib/blobbi-social-projection.ts @@ -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 = { + 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(); + 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)); +} diff --git a/src/blobbi/core/lib/blobbi-tag-schema.ts b/src/blobbi/core/lib/blobbi-tag-schema.ts index 17f96231..152023f6 100644 --- a/src/blobbi/core/lib/blobbi-tag-schema.ts +++ b/src/blobbi/core/lib/blobbi-tag-schema.ts @@ -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', diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 32037832..d3c9aedd 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -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 { + 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 diff --git a/src/blobbi/onboarding/lib/blobbi-preview.ts b/src/blobbi/onboarding/lib/blobbi-preview.ts index c94beeaa..bf5a5270 100644 --- a/src/blobbi/onboarding/lib/blobbi-preview.ts +++ b/src/blobbi/onboarding/lib/blobbi-preview.ts @@ -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, diff --git a/src/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx index fbabb94c..f235760b 100644 --- a/src/blobbi/rooms/components/BlobbiRoomHero.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -6,7 +6,7 @@ * Top padding accounts for the floating room header overlay. */ -import { useMemo, type CSSProperties } from 'react'; +import { memo, useMemo, type CSSProperties } from 'react'; import { Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle, Footprints, Loader2, @@ -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({
+ {/* Interaction reaction overlays — sparkles, bubbles, hearts */} + + +
{!isEgg && ( @@ -197,7 +218,7 @@ export function BlobbiRoomHero({ ); -} +}); // ─── Stats Crown ────────────────────────────────────────────────────────────── diff --git a/src/blobbi/rooms/components/BlobbiRoomShell.tsx b/src/blobbi/rooms/components/BlobbiRoomShell.tsx index 416c7083..671fece6 100644 --- a/src/blobbi/rooms/components/BlobbiRoomShell.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomShell.tsx @@ -94,6 +94,11 @@ export function BlobbiRoomShell({ const touchStartX = useReactRef(null); const onTouchStart = useCallback((e: React.TouchEvent) => { + // If the touch started on a food-drag handle (the carousel food button), + // skip the swipe — that gesture drives a food drag, not a room change. + // This check is synchronous (DOM attribute), so it works even before + // React re-renders with the drag state from the same pointerdown. + if ((e.target as HTMLElement).closest?.('[data-food-drag]')) return; touchStartX.current = e.touches[0].clientX; }, [touchStartX]); diff --git a/src/blobbi/rooms/components/ItemCarousel.tsx b/src/blobbi/rooms/components/ItemCarousel.tsx index ccf1c70d..fafc3cda 100644 --- a/src/blobbi/rooms/components/ItemCarousel.tsx +++ b/src/blobbi/rooms/components/ItemCarousel.tsx @@ -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({ )} + + e.stopPropagation()} + onOpenAutoFocus={(e) => e.preventDefault()} + > + {/* ── Action pills ── */} + {panel.step === 'actions' && ( +
+ {SOCIAL_ACTIONS.map(({ inventory }) => { + const meta = ACTION_METADATA[inventory]; + return ( + + ); + })} +
+ )} + + {/* ── Item carousel ── */} + {(panel.step === 'carousel' || panel.step === 'pending') && ( +
+
+ + + {activeAction && ACTION_METADATA[activeAction].icon}{' '} + {activeAction && ACTION_METADATA[activeAction].label} + +
+ {carouselEntries.length > 0 ? ( + + ) : ( +

+ No items available. +

+ )} +
+ )} + + {/* ── Success ── */} + {panel.step === 'success' && ( +
+ {ACTION_METADATA[panel.action].icon} + Sent! +
+ )} +
+ + ); +} diff --git a/src/components/BlobbiStateCard.tsx b/src/components/BlobbiStateCard.tsx index dae31314..6cc3065b 100644 --- a/src/components/BlobbiStateCard.tsx +++ b/src/components/BlobbiStateCard.tsx @@ -4,31 +4,49 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { BlobbiStageVisual, type BlobbiLookMode } from '@/blobbi/ui/BlobbiStageVisual'; import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi'; import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState'; +import { useBlobbiInteractions } from '@/blobbi/core/hooks/useBlobbiInteractions'; import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions'; import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe'; +import { ReactionSparkles, ReactionBubbles } from '@/blobbi/ui/ReactionOverlays'; +import { FloatingSocialHearts } from '@/blobbi/ui/FloatingSocialHearts'; +import type { InteractionReactionState } from '@/blobbi/ui/hooks/useInteractionReaction'; +import { cn } from '@/lib/utils'; interface BlobbiStateCardProps { event: NostrEvent; /** Controls eye tracking behavior. Default: 'forward' (eyes look straight ahead). */ lookMode?: BlobbiLookMode; + /** Temporary interaction reaction state (body animation, emotion override, particle overlays). */ + interactionReaction?: InteractionReactionState; } -export function BlobbiStateCard({ event, lookMode = 'forward' }: BlobbiStateCardProps) { +export function BlobbiStateCard({ event, lookMode = 'forward', interactionReaction }: BlobbiStateCardProps) { const companion = useMemo(() => parseBlobbiEvent(event), [event]); const isSleeping = companion?.state === 'sleeping'; const isEgg = companion?.stage === 'egg'; + // Fetch kind 1124 interactions targeting this Blobbi. + // Disabled for eggs: they do not participate in the social stat-loss/care flow. + // Not gated on socialOpen: past interactions must still affect projected + // status even after the owner disables social. The hook is disabled when + // companion is null (invalid event) and returns an empty array. + const { interactions } = useBlobbiInteractions(isEgg ? null : (companion ?? null)); + // ── Project stats forward in time, then resolve visual recipe ── // Feed cards show a snapshot, not a live ticker, so we call the pure // calculateProjectedDecay() once per render instead of using the // interval-based useProjectedBlobbiState hook. This gives us the // same decay math the room view uses (applyBlobbiDecay under the // hood) without any per-card setInterval overhead. + // + // When social interactions are available, they are layered on top + // of the decayed stats via the social projection pipeline. const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => { if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' }; - const { stats } = calculateProjectedDecay(companion); + const socialInteractions = interactions.length > 0 ? interactions : undefined; + const { stats } = calculateProjectedDecay(companion, undefined, socialInteractions); const result = resolveStatusRecipe(stats); @@ -37,24 +55,45 @@ export function BlobbiStateCard({ event, lookMode = 'forward' }: BlobbiStateCard const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated; return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label }; - }, [companion, isEgg, isSleeping]); + }, [companion, isEgg, isSleeping, interactions]); if (!companion) return null; + // During an active interaction reaction with an emotion override, the emotion + // prop drives the face instead of the recipe (recipe takes precedence when set). + const reactionActive = interactionReaction?.isActive ?? false; + const hasEmotionOverride = reactionActive && !!interactionReaction?.emotionOverride; + return (
{/* Blobbi visual — reflects current condition */}
- +
+ + {/* Interaction reaction overlays — sparkles, bubbles, hearts (not for eggs) */} + {!isEgg && ( + <> + + + + + )} +
{/* Name */} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 2ee57b38..e693b7fe 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -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({ ) : isBlobbiState ? ( }> - + ) : isUnknownKind ? ( @@ -763,16 +771,17 @@ export const NoteCard = memo(function NoteCard({ // ── Shared action buttons (used in all layouts) ── const actionButtons = ( -
+
); diff --git a/src/components/PostActionBar.tsx b/src/components/PostActionBar.tsx index bbd10446..6a4d205b 100644 --- a/src/components/PostActionBar.tsx +++ b/src/components/PostActionBar.tsx @@ -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({
{/* Reply / Comments */} {/* More */}
); diff --git a/src/components/widgets/BlobbiWidget.tsx b/src/components/widgets/BlobbiWidget.tsx index 9810a810..2fc2b4d5 100644 --- a/src/components/widgets/BlobbiWidget.tsx +++ b/src/components/widgets/BlobbiWidget.tsx @@ -272,6 +272,8 @@ interface BlobbiWidgetContentProps { } function BlobbiWidgetContent({ companion, onUseItem, onRest, isActionPending, isCurrentCompanion, isActiveFloatingCompanion, isUpdatingCompanion, onToggleCompanion }: BlobbiWidgetContentProps) { + // Projected state with decay only — owner surfaces do not pre-project social + // effects. Social effects are incorporated via explicit consolidation. const projected = useProjectedBlobbiState(companion); const defaultStats: BlobbiStats = { hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100 }; const stats = projected?.stats ?? defaultStats; diff --git a/src/index.css b/src/index.css index 2cb435c7..53757000 100644 --- a/src/index.css +++ b/src/index.css @@ -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 ───────────────────────────────────────────────────── */ /** diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index a208c833..3f2869d0 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -3,10 +3,16 @@ import { createPortal } from 'react-dom'; import { Link, useNavigate } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, TowelRack, X } from 'lucide-react'; +import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap, Refrigerator, ShowerHead, Candy, TowelRack, X, Activity, Users } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState'; +import { useBlobbiInteractions } from '@/blobbi/core/hooks/useBlobbiInteractions'; +import { useBlobbiActivityHistory } from '@/blobbi/core/hooks/useBlobbiActivityHistory'; +import { useCanonicalSync } from '@/blobbi/core/hooks/useCanonicalSync'; +import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items'; +import { timeAgo } from '@/lib/timeAgo'; import { useAppContext } from '@/hooks/useAppContext'; import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile'; import { useBlobbonautProfileNormalization } from '@/hooks/useBlobbonautProfileNormalization'; @@ -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 ( - <> - setShowAdoptionFlow(true)} - currentCompanion={profile?.currentCompanion} - /> - - {/* Adoption Flow Modal */} - - - setShowAdoptionFlow(false)} - /> - - - - ); - } - // ─── CASE I: Everything ready - show dashboard ─── // At this point: companion is BlobbiCompanion, selectedD is string (narrowed by Case H guard) // Note: Item use registration is handled by useBlobbiActionsRegistration hook above @@ -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(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(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(null); + + // Music/sing override — persistent while the activity is active (not auto-clearing). + // Separate from interactionReaction because music is a duration-based activity, + // not a short reward reaction. + const [musicOverrideEmotion, setMusicOverrideEmotion] = useState(null); // Status-based automatic reactions (recipe-first pipeline). // Uses projected stats (with decay applied) for accurate reactions. // Body effects (dirt, stink) are folded into the recipe by the resolver — // no separate bodyEffects prop needed. + // + // Override priority: 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 | 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 | undefined>(undefined); + const happyTimerRef = useRef | undefined>(undefined); + const crumbTimerRef = useRef | undefined>(undefined); + const safetyTimerRef = useRef | 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('[data-blobbi-visual]'); + if (el) { + requestAnimationFrame(() => { + if (!isActive()) return; + + const r = el.getBoundingClientRect(); + const mouthEl = el.querySelector('[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 ( @@ -1470,6 +1776,15 @@ function BlobbiDashboard({ onStartEvolution={handleStartEvolution} /> )} + {activeDrawer === 'activity' && ( + + )} {activeDrawer === 'more' && ( Quests + toggleDrawer('activity')}> + + + Activity + + toggleDrawer('more')}> @@ -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}`} /> )} + + {/* ─── Food drag ghost overlay ─── */} + {foodDragHook.drag && ( +
+ + {foodDragHook.drag.emoji} + +
+ )} + + {/* ─── Crumb burst overlay (chewing feedback) ─── */} + {crumbBurst && ( + + )} {/* ─── 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> = { 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(`${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 ( <> @@ -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 = { + feed: { label: 'Feed', icon: '🍎' }, + play: { label: 'Play', icon: '⚽' }, + clean: { label: 'Clean', icon: '🧼' }, + medicate: { label: 'Medicine', icon: '💊' }, +}; + +interface ActivityTabContentProps { + companion: BlobbiCompanion; + socialOpen: boolean; + onToggleSocial: (open: boolean) => Promise; + isSocialToggling: boolean; + isEgg: boolean; +} + +function ActivityTabContent({ companion, socialOpen, onToggleSocial, isSocialToggling, isEgg }: ActivityTabContentProps) { + // Use the history hook: fetches recent interactions WITHOUT checkpoint filtering, + // so consumed interactions remain visible in the activity history. + const { interactions: allInteractions, isLoading } = useBlobbiActivityHistory(isEgg ? null : companion); + + // Recency rule: if more than 20 interactions available, apply 24h filter. + // Otherwise show the most recent ones regardless of age. + const displayInteractions = useMemo(() => { + if (allInteractions.length <= 20) { + return allInteractions; + } + const cutoff = Math.floor(Date.now() / 1000) - 24 * 60 * 60; + return allInteractions.filter((ix) => ix.createdAt >= cutoff).slice(0, 20); + }, [allInteractions]); + + const socialToggleId = 'blobbi-social-toggle'; + + return ( +
+ {/* ─── Social Permission Toggle (hidden for eggs) ─── */} + {isEgg ? ( +
+ +

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

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

No recent activity

+
+ ) : ( + <> +

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

+
+ {displayInteractions.map((ix) => { + const actionInfo = INTERACTION_ACTION_DISPLAY[ix.action] ?? { label: ix.action, icon: '❓' }; + const item = ix.itemId ? getShopItemById(ix.itemId) : undefined; + + return ( +
+ {actionInfo.icon} + {actionInfo.label} + {item && ( + + {item.icon} {item.name} + + )} + + + {timeAgo(ix.createdAt)} + +
+ ); + })} +
+ + )} +
+ ); +} + +/** Small inline component to resolve + link a caretaker's display name. */ +function CaretakerLink({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const displayName = author.data?.metadata?.name ?? genUserName(pubkey); + const profilePath = getProfileUrl(pubkey, author.data?.metadata); + + return ( + e.stopPropagation()} + > + {displayName} + + ); +} + // ─── Blobbi Selector Page ───────────────────────────────────────────────────── interface BlobbiSelectorPageProps { @@ -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` — 2–4 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 */} + + + {/* Floating reward word — anchored above the head */} + + + ); +} + // ─── Hatch Ceremony Overlay ─────────────────────────────────────────────────── diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index d66e306f..623219c7 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -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("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 }) { ) : isBlobbiState ? ( }> - + ) : isBadgeAward ? ( @@ -2240,6 +2259,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) { onReply={() => setReplyOpen(true)} onMore={() => setMoreMenuOpen(true)} className="-mx-4 px-4" + compact={showBlobbiInteract} + extraButtons={showBlobbiInteract ? ( + + + + ) : undefined} />