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