Compare commits

...

29 Commits

Author SHA1 Message Date
filemon 286572777b Fix stat crown instability on room switch; hide Closet from navigation
Stat crown fix — root cause and solution:
The heroWidth measurement used a useRef + useEffect([], ...) pattern
that created a ResizeObserver once on mount. When switching rooms,
BlobbiRoomHero unmounts and remounts inside the new room component,
creating a new DOM element. But the ResizeObserver was still watching
the old detached element, so heroWidth went stale (often falling to
the default 375px or reading 0 from the detached node).

Fix: replaced useRef + useEffect with a callback ref (heroCallbackRef)
that disconnects the old ResizeObserver and creates a new one every
time the ref is assigned to a new element. This ensures heroWidth is
always measured from the currently-mounted hero container.

Additionally, the arc spread and radius values were too extreme on
desktop (120/190/230 degrees, 300px radius) which made the stats
fly far apart. Now using balanced moderate values:
- Mobile: 80/110/140 degrees
- Desktop: 90/130/160 degrees
- Radius: smooth 110px → 200px interpolation based on container width

This produces a stable, moderate crown that looks the same on initial
render and after room switches, with desktop having slightly more
breathing room than mobile without being exaggerated.

Closet hidden:
Removed 'closet' from DEFAULT_ROOM_ORDER with a comment explaining
how to re-enable it. The BlobbiClosetRoom component, its type in
BlobbiRoomId, its ROOM_META entry, and its ROOM_COMPONENTS mapping
all remain intact — only the navigation sequence excludes it.
2026-04-06 02:34:57 -03:00
filemon 7fd4b7ab69 Polish carousel stability, desktop stat spacing, poop UX, and visual harmony
Carousel stability:
- Focused item container is now overflow-hidden with explicit dimensions
  (w-20 h-[4.5rem] / sm:w-24 sm:h-[5.5rem]) so no content can push
  the layout wider/taller
- Label uses a fixed max-width (w-16 sm:w-20) with text-center truncate
  so changing items never shifts the arrows
- Preview slots also have overflow-hidden
- Empty state matches the populated carousel height

Desktop stat spacing (mobile unchanged):
- Arc spread widened to 120/190/230 degrees (was 100/160/200)
- Radius scaled up to 300px max (was 260px)
- This gives stat indicators much more breathing room on desktop
  without affecting the mobile layout at all

Poop placement refinement:
- Pre-computed safe-zone positions in lower-left and lower-right
  corners, avoiding the central Blobbi hero area
- Positions stored on each PoopInstance so they're stable
- Shovel mode: active state shows ring indicator around the button
  and label changes to 'Done'; poop gets drop-shadow-lg when
  hoverable; hover:scale-150 for clearer interaction feedback

Care room fix:
- Treat button no longer calls invalid 'interact' DirectAction;
  uses a small food item from shop instead
- Both left/right slots always render the same fixed width
  (RoomActionButton or w-14/w-20 spacer) so switching between
  hygiene/medicine items never causes layout shift

Visual harmony:
- Closet room bottom bar now uses the same flex items-center
  justify-center horizontal layout instead of a different
  flex-col structure, matching the rhythm of all other rooms
2026-04-06 02:28:53 -03:00
filemon c9525a0233 Add carousel stability, sleep overlay, care room conditional actions, poop system
Multiple polish and interaction improvements:

1. Carousel stability:
   Focused item area now uses fixed dimensions (w-20 h-16 / sm:w-24 sm:h-20)
   so the carousel never reflows when switching between items. Arrows stay
   perfectly stable. Preview slots also have fixed w-10 h-12 dimensions.

2. Desktop stat spacing:
   Arc spread widened further on desktop: 100/160/200 degrees (was 90/140/180)
   with radius up to 260px (was 220px). Stats now have much more breathing
   room on desktop while mobile stays unchanged.

3. Bedroom sleep button centered:
   Sleep/wake button is now in the center of the bottom bar instead of
   right-aligned, making the bedroom feel more focused.

4. Sleep dark overlay:
   When Blobbi is sleeping, a radial-gradient dark overlay covers the
   entire room shell (all rooms, not just bedroom). Uses z-20 with
   pointer-events-none so controls remain usable. Scoped to the room
   shell only — app header, bottom nav, and other app UI stay unaffected.

5. Renamed Bathroom → Care Room:
   Room label changed from 'Bathroom' to 'Care Room' with icon changed
   from bathtub to bandage emoji, since the room also contains medicine.

6. Care room conditional side actions:
   ItemCarousel now supports onFocusChange callback and meta field.
   When a hygiene item is focused: Towel (left) + Shower (right).
   When a medicine item is focused: Lollipop/Treat (left) + empty (right).
   Side buttons swap reactively as the user cycles through items.

7. Temporary local poop system:
   - poop-system.ts: ephemeral generation based on hunger >= 95 (overfeed,
     kitchen-only) and hours since last interaction (time-based, random room)
   - Generated once on mount, no persistence
   - Poop appears as floating emoji in affected rooms
   - Kitchen shows a Shovel button when any poop exists anywhere
   - Shovel mode: clicking poop removes it and awards 5 XP via toast
   - All rooms accept RoomPoopState prop for future expansion
   - BlobbiRoomContext extended with lastFeedTimestamp
2026-04-06 02:05:19 -03:00
filemon 0b9cd5e1cb Unify room surface, fix desktop proportions, clear mobile bottom nav
Three structural layout fixes:

1. Remove 'background cut' feeling:
   - Room header (label + dots) is now absolutely positioned over the
     room content instead of being a separate flex block above it. This
     eliminates the stacked-panels look where each section appeared to
     have its own background surface.
   - Hero container no longer uses overflow-hidden; uses pt-10 for
     header clearance instead. The room reads as one continuous scene.
   - Room shell content area fills entirely; header and nav arrows
     float over it as overlays.

2. Desktop proportions tuned (mobile preserved):
   - Blobbi visual reduced on desktop: size-72/size-80/size-96 ladder
     (was size-80/size-[28rem]/size-[32rem])
   - Stats crown arc spread widened on desktop: 90/140/180 degrees
     (was 80/120/160), with radius up to 220px (was 180px). This gives
     the stat indicators more breathing room on wide screens.
   - Stats crown margin: mb-4 sm:mb-8 (tighter on mobile, roomier on desktop)
   - Mobile stat sizing and spacing unchanged from previous pass.

3. Mobile/tablet bottom nav clearance:
   - New shared ROOM_BOTTOM_BAR_CLASS constant in room-layout.ts
   - On max-sidebar (below 900px), bottom bars add padding:
     calc(var(--bottom-nav-height) + env(safe-area-inset-bottom) + 1rem)
   - This pushes room controls above the app's fixed bottom navigation
     (Feed/Search/Notifications/Profile)
   - On desktop (sidebar:), normal pb-6 applies since there's no
     bottom nav bar.
   - All 6 rooms now use the shared class for consistent clearance.
2026-04-06 01:32:43 -03:00
filemon a2600d1caa Fix room layout proportions, responsive sizing, and visual composition
Addresses layout issues where the room felt cut off and controls were
disproportionate, especially on mobile.

Key changes:

RoomActionButton — responsive sizing:
  Mobile: size-14 circle, size-7 icons
  Desktop: size-20 circle, size-9 icons
  Tighter gap-1 and text-[10px] labels on mobile

ItemCarousel — responsive behavior:
  Mobile: focused item only + compact arrows, no prev/next previews
  Desktop: focused item + translucent side previews
  Smaller focused item (text-4xl mobile, text-5xl desktop)

BlobbiRoomHero — reduced visual footprint:
  Blobbi visual: tighter responsive scale (size-48 -> size-60 -> size-80)
  Stats crown: reduced margin (mb-6 sm:mb-10 vs mb-14)
  Stat indicators: size-14 sm:size-[4.5rem] (was size-[4.5rem] sm:size-20)
  Glow blur reduced on mobile (-m-16 vs -m-24)
  overflow-hidden to prevent hero from clipping

HomeRoom — unified bottom bar:
  Removed -mt-10 negative margin hack that caused visual clipping
  Photo, Carousel, and Companion now sit in one flex row with
  items-center alignment, forming a single cohesive bottom composition

All rooms — standardized bottom bar pattern:
  Consistent px-3 sm:px-6 pb-4 sm:pb-6 pt-1 spacing
  flex items-center justify-between gap-1 sm:gap-3 layout
  Spacer divs match button widths (w-14 sm:w-20) for balance

Room shell content area uses flex flex-col to ensure rooms
fill the available vertical space correctly.
2026-04-06 01:15:11 -03:00
filemon 0722d900a2 Polish room UI: single-focus carousel, unified action buttons, add Rest room
Major improvements to room layout and visual consistency:

- ItemCarousel: single-focus carousel showing one main item at center
  with translucent prev/next previews and left/right navigation arrows.
  Replaces the horizontal scroll rows in Kitchen, Care, and Home rooms.

- RoomActionButton: unified circular button component matching the
  original Photo and Companion button visual language (radial glow
  background, consistent size, hover lift, label beneath).
  Used in all rooms for consistent visual weight.

- BlobbiRestRoom: new dedicated bedroom for sleep/wake behavior,
  with a Moon/Sun toggle as a RoomActionButton on the bottom-right.
  Sleep/wake removed from Home room.

- Updated default room order: care -> kitchen -> home -> hatchery ->
  rest -> closet (looped in both directions).

- All room bottom bars now use consistent px-4 sm:px-8 pb-6 spacing
  with items-start justify-between layout for left/center/right zones.

- HatcheryRoom left/right buttons (Blobbis, Quests) upgraded to
  RoomActionButton with badge support.
2026-04-06 01:02:14 -03:00
filemon 918814371c Refactor Blobbi dashboard into room-based navigation system
Replace the tab/drawer layout with a room-based system where each room
represents a specific area of Blobbi interaction:
- Care (bathroom): hygiene tools, medicine, towel, shower
- Kitchen: food carousel, fridge modal
- Home: toys, music, sing, photo, companion, lamp/sleep
- Hatchery: hatch/evolve progress, quests sheet, Blobbis sheet
- Closet: placeholder for future wardrobe

Architecture:
- Room types, config, and navigation helpers in src/blobbi/rooms/lib/
- BlobbiRoomShell manages current room state and left/right navigation
- BlobbiRoomHero shared component for the Blobbi visual + stats crown
- Each room is a self-contained component receiving BlobbiRoomContext
- Default room order is data-driven (DEFAULT_ROOM_ORDER array) to
  support future per-user customization
- Navigation direction tracked for future animated transitions
- All existing hooks, mutations, and flows preserved unchanged
2026-04-06 00:46:04 -03:00
Alex Gleason 517a72cce7 Fix profile avatar/banner lightbox appearing behind right sidebar
Portal ProfileImageLightbox to document.body, matching the fix
already applied to the shared Lightbox component. Without the
portal, the lightbox was trapped inside the center column's z-0
stacking context from MainLayout, causing the right sidebar
(a sibling outside that context) to paint on top.
2026-04-05 20:37:09 -05:00
Chad Curtis 6fc68766c9 Merge branch 'refactor/blobbi-remove-item-quantity-selection' into 'main'
Remove quantity selectors from Blobbi item usage flows

See merge request soapbox-pub/ditto!163
2026-04-06 00:38:27 +00:00
filemon ae81c13cc1 Merge branch 'main' into fix-items-blobbi-companion 2026-04-05 21:21:31 -03:00
filemon 41358d27ce Update comments and docs to reflect item-as-ability model
Replace outdated references to 'inventory items', 'consume',
'quantity', and 'storage decrement' across 14 files. Comments
now consistently describe items as reusable abilities sourced
from the shop catalog, not consumable inventory.
2026-04-05 21:18:35 -03:00
Chad Curtis ac8bffba23 Merge branch 'fix-items-blobbi-companion' into 'main'
Remove inventory ownership requirement from Blobbi companion items

See merge request soapbox-pub/ditto!162
2026-04-05 23:59:18 +00:00
filemon 748365de40 Remove quantity selectors from Blobbi item usage flows
Items are now single-use abilities — tap item, press Use, effect
happens immediately. No confirmation dialogs or quantity selectors.

Changes:
- Remove BlobbiUseItemConfirmDialog and InventoryUseConfirmDialog
- Remove quantity state, selectors, and multi-use loops from modals
- Simplify mutation hooks to always apply item effects once
- Drop quantity parameter from UseItemFunction type signature
- Update all call sites through the full stack (BlobbiPage, context,
  companion layer, companion item use hook)
2026-04-05 20:58:31 -03:00
filemon 361f8b9506 Remove inventory ownership requirement from Blobbi companion items
Items are now treated as abilities/tools unlocked by stage, not
consumable inventory that must be purchased. All catalog items are
shown in the companion action menu regardless of inventory quantity.

Changes:
- Source items from shop catalog instead of user inventory storage
- Remove quantity validation and storage decrement on item use
- Remove quantity badges and 'in inventory' text from all modals
- Keep stage-based filtering (egg vs baby/adult restrictions)
- Cap quantity selector at 99 instead of inventory count
2026-04-05 19:59:30 -03:00
Alex Gleason c1ec7a25ed Use published_at tag to show created/updated verbs in event action headers
Replaceable and addressable event headers now distinguish between
first publish and subsequent updates using the published_at tag:
- published_at == created_at → 'created' verb (e.g. 'created an emoji pack')
- published_at != created_at → 'updated' verb (e.g. 'updated an emoji pack')
- no published_at → 'shared' fallback for backward compatibility
2026-04-05 15:12:19 -05:00
Alex Gleason 272586d033 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-05 14:54:41 -05:00
Alex Gleason c77c098843 Add NIP-24 published_at to useNostrPublish for replaceable/addressable events
Extend useNostrPublish with an optional `prev` property on the event
template. For replaceable and addressable kinds, the hook automatically
manages published_at:

- First publish (no prev): set published_at equal to created_at
- Update (prev provided): preserve published_at from the old event
- Old event lacks published_at: don't fabricate one
- Caller already set published_at in tags: leave it alone

Callers pass `prev` when they have the old event from fetchFreshEvent,
giving the hook everything it needs without extra network requests.
Updated all 11 call sites that publish replaceable or addressable events.
Documents the prev convention in AGENTS.md.
2026-04-05 14:23:58 -05:00
Chad Curtis ea7afa94f7 Fix infinite scroll on custom profile tab feeds reloading same content
Two issues caused custom tab feeds (e.g. Magic Decks) to loop:

1. ProfileSavedFeedContent flattened pages without deduplication, so
   events returned by multiple pages rendered as visible duplicates.

2. useTabFeed only stopped paginating when rawCount === 0. For
   addressable events the relay keeps returning the same latest
   versions, so rawCount never hit zero. Changed to rawCount < limit
   (relay returned fewer than requested = exhausted).
2026-04-05 12:23:25 -05:00
Chad Curtis 0c29506402 Fix all 50 ESLint warnings by extracting non-component exports and adding missing deps
- Extract utility functions from component files into dedicated modules
  to fix react-refresh/only-export-components warnings:
  - parseBadgeDefinition -> src/lib/parseBadgeDefinition.ts
  - parseProfileBadges -> src/lib/parseProfileBadges.ts
  - getColors, paletteToTheme -> src/lib/colorMomentUtils.ts
  - parseDimToAspectRatio, eventToMediaItem -> src/lib/mediaUtils.ts
  - isAudioUrl, isImageUrl, isVideoUrl -> src/lib/mediaTypeDetection.ts
  - buildKindOptions, parseSelectedKinds -> src/lib/feedFilterUtils.ts
  - useVideoThumbnail -> src/hooks/useVideoThumbnail.ts
  - useEnvelopeDimensions -> src/hooks/useEnvelopeDimensions.ts
  - usePortalContainer -> src/hooks/usePortalContainer.ts
  - useAudioPlayer -> src/contexts/audioPlayerContextDef.ts
  - SubHeaderBar context/hooks -> src/components/SubHeaderBarContext.ts
  - EmotionDev hooks -> src/blobbi/dev/useEmotionDev.ts
  - BlobbiActions context def -> BlobbiActionsContextDef.ts

- Remove export from internal-only functions (useEventComments,
  parseEmojiPack, useScrollCarets, formatEffectSummary, getSortedEffectEntries)

- Fix react-hooks/exhaustive-deps warnings by adding missing dependencies
  to useEffect/useCallback/useMemo hooks across 14 files

- Fix logical expression dependency warnings by wrapping conditional
  values (tasks, pubkeys, authorPubkeys) in useMemo

- Move module-level constants (CORE_TAB_IDS, CORE_TAB_LABELS,
  DEFAULT_TAB_LABELS) out of ProfilePage component body

- Reorder usePushNotifications hooks so syncPreferences is defined
  before enable to fix block-scoped variable error
2026-04-05 11:57:31 -05:00
Chad Curtis b0609e7877 Make reaction emoji visible per-row in interactions modal
Replace grouped-by-emoji layout with a flat list where each reaction
row shows an inline emoji badge (similar to the zap amount badge).
Add an emoji summary bar at the top when multiple emoji types are
present. This makes it immediately obvious who reacted with what.
2026-04-05 11:07:55 -05:00
Chad Curtis 946be28b81 Use standard author layout for follow pack/set cards instead of content-first 2026-04-05 10:59:01 -05:00
Chad Curtis 89250c7472 Add kind action headers for follow packs and follow sets 2026-04-05 10:56:22 -05:00
Chad Curtis cfc7a0d31c Extract useActiveTabIndicator hook to deduplicate TabButton and SortableTabChip
The scroll-aware active indicator reporting and scroll listener logic was
duplicated between TabButton and SortableTabChip. Extract into a shared
useActiveTabIndicator hook in SubHeaderBar.
2026-04-05 10:47:34 -05:00
Chad Curtis 21003e3aed Add edit button for custom profile tabs and clear underline on tab removal
- Add pencil icon to SortableTabChip for editing existing custom tabs
- Wire onEdit to open ProfileTabEditModal with the existing tab data
- Clear the active arc underline when an active tab is removed (cleanup in useLayoutEffect)
- Round dnd-kit transform values to avoid sub-pixel rendering issues
2026-04-05 10:42:23 -05:00
Chad Curtis 93e8a6290f Merge branch 'fix/167-post-compose-box-renders-too-small-and-unclickable' into 'main'
fix: intermittent mobile compose modal collapse and unclickable input

Closes #167

See merge request soapbox-pub/ditto!146
2026-04-05 15:28:55 +00:00
Dmitriy E 47831ffa64 fix: intermittent mobile compose modal collapse and unclickable input 2026-04-05 10:27:43 -05:00
Chad Curtis 1533420320 Fix desktop tab overflow and add interest tab management in settings
SubHeaderBar: add left/right chevron scroll arrows on desktop when tabs
overflow, with gradient fade. Auto-scroll active tab into view and keep
arc hover/active indicators aligned during horizontal scroll.

ContentSettings: add Interest Tabs section with inline add/remove for
hashtags and geotags. Remove buttons always visible (mobile-friendly),
X icons use strokeWidth 4.
2026-04-05 10:20:58 -05:00
Chad Curtis e3ef542875 Fix desktop tab bar overflow: add scroll arrows and auto-scroll active tab into view
On desktop, overflowing feed tabs were completely inaccessible since the
scrollbar was hidden and there was no swipe gesture. Add left/right
chevron scroll buttons that appear only on desktop when tabs overflow,
with gradient fade indicators. Also auto-scrolls the active tab into
view when switching tabs, and keeps the arc hover/active indicators
aligned during horizontal scroll.
2026-04-05 10:15:01 -05:00
Chad Curtis 3bf55990c0 Fix missing bottom border on collapsed thread expand button
When a depth-collapsed 'Show X more replies' button was the last item
in a reply sequence, it lacked a bottom border separator. Added an
isLast prop to ExpandThreadButton that adds border-b when the button
terminates the visual sequence.
2026-04-05 09:50:52 -05:00
123 changed files with 4362 additions and 3381 deletions
+28 -4
View File
@@ -699,23 +699,47 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
#### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
- **Update (`prev` provided)**: `published_at` is preserved from the old event
- **Old event lacks `published_at`**: nothing is fabricated
- **Caller already set `published_at` in tags**: left alone
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
+2 -27
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.5.2",
"version": "2.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.5.2",
"version": "2.6.0",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -5699,7 +5699,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5713,7 +5712,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5727,7 +5725,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5741,7 +5738,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5755,7 +5751,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5769,7 +5764,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5783,7 +5777,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5797,7 +5790,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5811,7 +5803,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5825,7 +5816,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5839,7 +5829,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5853,7 +5842,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5867,7 +5855,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5881,7 +5868,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5895,7 +5881,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5909,7 +5894,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5923,7 +5907,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5937,7 +5920,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5951,7 +5933,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5965,7 +5946,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5979,7 +5959,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5993,7 +5972,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6007,7 +5985,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6021,7 +5998,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6035,7 +6011,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1,19 +1,16 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
@@ -37,8 +34,8 @@ interface BlobbiActionInventoryModalProps {
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
@@ -49,24 +46,19 @@ export function BlobbiActionInventoryModal({
onOpenChange,
action,
companion,
profile,
profile: _profile,
onUseItem,
onOpenShop,
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// State for confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Filter inventory by action type, respecting egg-compatible effects
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
@@ -74,46 +66,9 @@ export function BlobbiActionInventoryModal({
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowConfirmDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset after starting use
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseConfirmDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const handleOpenShop = () => {
onOpenChange(false);
onOpenShop();
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
onUseItem(item.itemId);
};
return (
@@ -161,14 +116,10 @@ export function BlobbiActionInventoryModal({
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-4">
You don't have any items for this action. Visit the shop to get some!
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for this action at your Blobbi's current stage.
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
@@ -181,7 +132,7 @@ export function BlobbiActionInventoryModal({
item={item}
companion={companion}
action={action}
onUse={() => handleSelectItem(item)}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
@@ -190,24 +141,6 @@ export function BlobbiActionInventoryModal({
)}
</div>
</DialogContent>
{/* Confirmation Dialog with Quantity Selector */}
{selectedItem && (
<BlobbiUseItemConfirmDialog
open={showConfirmDialog}
onOpenChange={handleCloseConfirmDialog}
item={selectedItem}
companion={companion}
action={action}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
@@ -238,15 +171,12 @@ function BlobbiInventoryUseRow({
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
// For eggs using medicine, show health preview
// Eggs use the 3-stat model: health, hygiene, happiness
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
@@ -255,7 +185,6 @@ function BlobbiInventoryUseRow({
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
@@ -280,16 +209,12 @@ function BlobbiInventoryUseRow({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs shrink-0">
x{item.quantity}
</Badge>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -308,7 +233,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -350,7 +274,6 @@ function BlobbiInventoryUseRow({
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -369,7 +292,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -393,222 +315,3 @@ function BlobbiInventoryUseRow({
</div>
);
}
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
interface BlobbiUseItemConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function BlobbiUseItemConfirmDialog({
open,
onOpenChange,
item,
companion,
action,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: BlobbiUseItemConfirmDialogProps) {
const actionMeta = ACTION_METADATA[action];
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes for the selected quantity
const statPreview = useMemo(() => {
if (!item.effect) return { normalChanges: [], eggChanges: [] };
if (isEgg && isMedicine) {
// Calculate health change for N items
const healthDelta = item.effect.health ?? 0;
let currentHealth = companion.stats.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
}
const totalDelta = currentHealth - (companion.stats.health ?? 0);
return {
normalChanges: [],
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
};
}
if (isEgg && isClean) {
// Calculate hygiene and happiness changes for N items
const hygieneDelta = item.effect.hygiene ?? 0;
const happinessDelta = item.effect.happiness ?? 0;
let currentHygiene = companion.stats.hygiene ?? 0;
let currentHappiness = companion.stats.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
}
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
return { normalChanges: [], eggChanges: changes };
}
// Normal stats preview - simulate N applications
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const changes: Array<{ stat: string; delta: number }> = [];
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
changes.push({ stat, delta });
}
}
return { normalChanges: changes, eggChanges: [] };
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>{actionMeta.label}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{hasChanges && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<div className="flex flex-wrap gap-2">
{statPreview.normalChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
{statPreview.eggChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -98,13 +98,6 @@ export function InlineSingCard({
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
@@ -138,6 +131,13 @@ export function InlineSingCard({
}
}, [audioUrl, cleanupPlayback]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, [cleanupAll]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
+16 -16
View File
@@ -82,22 +82,6 @@ export function SingModal({
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open]);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
@@ -142,6 +126,22 @@ export function SingModal({
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open, cleanup, resetRecording]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
@@ -168,7 +168,7 @@ export function useActiveTaskProcess(
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = activeResult?.tasks ?? [];
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
@@ -73,7 +73,7 @@ export interface UseBlobbiDirectActionParams {
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't consume inventory items.
* Direct actions (play_music, sing) don't require selecting an item.
* They directly affect happiness stat.
*
* This hook:
@@ -6,19 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
@@ -37,23 +33,19 @@ import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an inventory item
* Request payload for using an item on a Blobbi companion
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
* Result of using an item on a Blobbi companion
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
@@ -71,9 +63,9 @@ export interface UseBlobbiUseInventoryItemParams {
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
@@ -86,29 +78,29 @@ export interface UseBlobbiUseInventoryItemParams {
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an inventory item on a Blobbi companion.
* Hook to use an item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog — no
* inventory ownership or quantity is required.
*
* This hook:
* 1. Validates the companion stage (eggs can't use items)
* 2. Validates the item exists in storage
* 3. Ensures canonical format before action
* 4. Applies item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
* 6. Decrements item from profile storage (kind 11125)
* 7. Invalidates relevant queries
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
updateProfileEvent: _updateProfileEvent,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -122,11 +114,6 @@ export function useBlobbiUseInventoryItem({
throw new Error('Profile not found');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -201,78 +188,25 @@ export function useBlobbiUseInventoryItem({
}
}
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
// ─── Apply Item Effects (single use) ───
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
// Eggs use the 3-stat model: health, hygiene, happiness
// Medicine with health effect directly affects the egg's health stat
// hunger and energy remain fixed at 100 for eggs
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
statsUpdate.health = currentHealth.toString();
// Track total actual change (may be less than healthDelta * quantity due to clamping)
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
// Apply decayed values for other egg stats
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
// Egg clean/hygiene handling:
// Hygiene items affect the egg's hygiene stat
// Some hygiene items also give happiness (e.g., bubble bath)
// hunger and energy remain fixed at 100 for eggs
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
// Only count items that INCREASED hygiene or happiness (positive effects only)
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHygiene = currentHygiene;
const prevHappiness = currentHappiness;
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
// Count as effective if hygiene OR happiness increased (positive effects only)
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
if (hygieneIncreased || happinessIncreased) {
effectiveItemCount++;
}
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -283,58 +217,12 @@ export function useBlobbiUseInventoryItem({
statsChanged.happiness = totalHappinessChange;
}
// Apply decayed health
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
// Use action-aware effectiveness checking for XP calculation
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
const effect = shopItem.effect;
for (let i = 0; i < quantity; i++) {
const prevStats = { ...currentStats };
currentStats = applyItemEffects(currentStats, effect);
// Action-aware effectiveness check:
// Only count INTENDED positive effects, not negative side effects
let isEffective = false;
if (action === 'feed') {
// Feed: count when hunger/energy/health/happiness INCREASE
// Do NOT count hygiene decrease (that's a side effect)
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
} else if (action === 'clean') {
// Clean: count when hygiene or happiness INCREASES
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hygieneIncreased || happinessIncreased;
} else if (action === 'medicine') {
// Medicine: count when health/energy/happiness INCREASE
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = healthIncreased || energyIncreased || happinessIncreased;
} else if (action === 'play') {
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
// Playing naturally consumes energy, so energy decrease counts as valid
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
isEffective = happinessIncreased || energyDecreased;
}
if (isEffective) {
effectiveItemCount++;
}
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -367,11 +255,8 @@ export function useBlobbiUseInventoryItem({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
@@ -391,48 +276,25 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// Only decrement storage if the item actually exists in inventory.
// Items are free to use regardless of inventory state.
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
if (hasItemInStorage) {
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
}
// No query invalidation needed — the optimistic updates above keep the
// 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).
return {
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity, xpGained }) => {
onSuccess: ({ itemName, action, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
+22 -24
View File
@@ -2,23 +2,23 @@
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
* Item-based care actions (use a shop catalog item on the companion)
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
* All Blobbi actions (item-based + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
};
/**
* Action metadata for UI display (inventory actions)
* Action metadata for UI display (item-based care actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
};
/**
* Action metadata for direct actions (non-inventory)
* Action metadata for direct actions (no item required)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
@@ -270,10 +270,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved inventory item with shop metadata
* Resolved catalog item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
@@ -285,7 +285,7 @@ export interface ResolvedInventoryItem {
}
/**
* Options for filtering inventory by action
* Options for filtering catalog items by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
@@ -376,7 +374,7 @@ export function decrementStorageItem(
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
* Stages that can use general items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
@@ -409,14 +407,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
* Check if a companion can use a specific item action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all inventory actions at the domain layer.
// All stages can technically use all item actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
@@ -442,7 +440,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Check if a companion can use general items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
+9 -11
View File
@@ -7,8 +7,8 @@
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - Item actions (feed, play, clean, medicine) give varied XP per action type
* - Direct actions (sing, play_music) give moderate XP
* - XP accumulates across all life stages and never resets
*/
@@ -17,19 +17,18 @@ import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-actio
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
* Base XP values for item-based care actions (feed, play, clean, medicine).
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is costly and critical - highest inventory XP
medicine: 10, // Medicine is critical - highest item XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
* These actions don't require selecting an item.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
@@ -58,11 +57,10 @@ export function calculateActionXP(action: BlobbiAction): number {
}
/**
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
* Calculate XP gain for an item-based care action.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @param quantity - Number of times performed (always 1 in current usage)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
@@ -88,8 +86,8 @@ export function applyXPGain(currentXP: number | undefined, xpGain: number): numb
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (for inventory actions)
* @returns Object with xpGained and total quantity
* @param quantity - Number of times the action was performed (always 1 in current usage)
* @returns Object with xpGained and quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
}
try {
const result = await contextUseItem(item.id, action, 1);
const result = await contextUseItem(item.id, action);
if (result.success) {
if (import.meta.env.DEV) {
@@ -19,7 +19,7 @@ import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
import { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
@@ -102,7 +102,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection('right');
setTargetX(targetX);
}, [bounds.maxX]);
}, [bounds.maxX, motionRef]);
/**
* Generate a random observation target on screen.
@@ -136,7 +136,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection(newDirection);
setTargetX(targetXPos);
}, [bounds, generateObservationTarget]);
}, [bounds, generateObservationTarget, motionRef]);
// Make a decision about what to do next
const makeDecision = useCallback(() => {
@@ -176,7 +176,7 @@ export function useBlobbiCompanionState({
// Schedule next decision
const duration = transition.duration ?? randomDuration(config.idleTime);
timerRef.current = window.setTimeout(makeDecision, duration);
}, [isActive, isSleeping, bounds, state, config, startObservation]);
}, [isActive, isSleeping, bounds, state, config, startObservation, motionRef]);
// Handle reaching target
const onReachedTarget = useCallback(() => {
@@ -255,7 +255,7 @@ export function useBlobbiCompanionState({
clearTimeout(timerRef.current);
}
};
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
// Pause decisions while dragging
// We poll isDragging via interval since motionRef changes don't trigger re-renders
@@ -15,17 +15,15 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
import { useBlobbiItemUse } from './useBlobbiItemUse';
import {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
type BlobbiActionsContextInternal,
} from './BlobbiActionsProvider';
} from './BlobbiActionsContextDef';
// Re-export everything from the provider module for backward compatibility
// Re-export types and context from the def module for backward compatibility
export {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
@@ -64,13 +62,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
// Create stable useItem function that:
// 1. Uses registered function if available (from BlobbiPage)
// 2. Falls back to built-in hook if no registration
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Try registered function first (from BlobbiPage)
if (context?.registerRef.current) {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using registered item-use function');
}
return context.registerRef.current(itemId, action, quantity);
return context.registerRef.current(itemId, action);
}
// Check if fallback can handle it
@@ -88,7 +86,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using fallback item-use hook');
}
return fallbackItemUse.useItem(itemId, action, quantity);
return fallbackItemUse.useItem(itemId, action);
}, [context, fallbackItemUse]);
// Determine canUseItems: true if registered OR fallback can use
@@ -136,14 +134,14 @@ export function useBlobbiActionsRegistration(
useItemRef.current = useItemFn;
// Create a stable wrapper that delegates to the ref
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
if (!useItemRef.current) {
return {
success: false,
error: 'Item use function not available',
};
}
return useItemRef.current(itemId, action, quantity);
return useItemRef.current(itemId, action);
}, []);
// Update refs and notify only when canUseItems actually changes
@@ -0,0 +1,75 @@
/**
* BlobbiActionsContextDef
*
* Lightweight context definition and types for the Blobbi actions system.
* Separated from the provider component to avoid react-refresh warnings.
*/
import { createContext } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item (always uses once).
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
@@ -10,75 +10,13 @@
* BlobbiPage, both of which are lazy-loaded.
*/
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item.
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
quantity?: number
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an inventory item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
import {
BlobbiActionsContext,
type UseItemFunction,
type BlobbiActionsContextInternal,
} from './BlobbiActionsContextDef';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -1,26 +1,24 @@
/**
* HangingItems
*
* Displays inventory items as hanging elements from the top of the screen.
* Displays available items as hanging elements from the top of the screen.
* Each item appears as a circle connected to the top by a thin vertical line,
* creating a playful, spatial feel.
*
* Items are reusable abilities sourced from the shop catalog — they are
* always available and not consumed on use.
*
* State Model:
* - Container states: hidden → opening → open → closing → hidden
* - Hanging items = available inventory that can still be released
* - Hanging items = catalog items available for the selected action
* - Released/dropped items = instances currently in the world (tracked with unique IDs)
* - Multiple instances of the same item type can exist simultaneously on the ground
*
* Key Design Principle:
* The hanging row represents "releasable quantity" - clicking releases ONE instance
* and immediately decrements the visible quantity. A new hanging copy remains if
* quantity > 1. The released instance tracks separately with a unique instance ID.
*
* Features:
* - Smooth open/close slide animations (items descend/ascend)
* - Thin vertical lines from the top of screen
* - Circular containers for hanging items
* - Click releases item: one instance falls, remaining quantity stays hanging
* - Click releases item: one instance falls to the ground
* - Multiple dropped instances of same item type can exist
* - Contact detection: items auto-use when touching Blobbi
* - Click-to-use: click landed items to use them
@@ -119,7 +117,7 @@ interface HangingItemsProps {
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
/**
* Callback when an item is collected by Blobbi (contact).
* @deprecated Use onItemUse instead for proper item consumption flow.
* @deprecated Use onItemUse instead for proper item-use flow.
*/
onItemCollected?: (item: CompanionItem) => void;
/**
@@ -156,7 +154,7 @@ const HANGING_CONFIG = {
baseFallDistance: 500,
/** Ground offset from bottom of viewport */
defaultGroundOffset: 40,
/** Size of quantity badge */
/** Size of badge (unused — kept for config consistency) */
badgeSize: 20,
/** Size of landed item hitbox for contact detection */
landedItemSize: 40,
@@ -406,7 +404,7 @@ export function HangingItems({
// Track how many instances of each item type have been released (not yet used)
// Key: item.id (type ID), Value: count of released instances
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
// Counter for generating unique instance IDs
const instanceCounterRef = useRef(0);
@@ -566,7 +564,7 @@ export function HangingItems({
// Start the loop
animationRef.current = requestAnimationFrame(animate);
}, []);
}, [calculateFallDuration]);
// Cleanup animation on unmount
useEffect(() => {
@@ -670,7 +668,7 @@ export function HangingItems({
});
// Also remove from zone tracking
itemsInZoneRef.current.delete(instanceId);
// Decrement the released count for this item type (since the instance is now consumed)
// Decrement the released count for this item type (instance removed from screen)
setReleasedCountByItemId(prev => {
const next = new Map(prev);
const currentCount = next.get(item.id) || 0;
@@ -985,15 +983,9 @@ export function HangingItems({
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
};
// Calculate hanging items with their remaining quantities
// An item appears in the hanging row if (quantity - releasedCount) > 0
const hangingItems = items
.map(item => {
const releasedCount = releasedCountByItemId.get(item.id) || 0;
const remainingQuantity = item.quantity - releasedCount;
return { ...item, quantity: remainingQuantity };
})
.filter(item => item.quantity > 0);
// All items are always visible — they are abilities, not consumable inventory.
// No quantity filtering needed.
const hangingItems = items;
// Should we render the hanging container?
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
@@ -1033,7 +1025,7 @@ export function HangingItems({
>
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
<p className="text-sm text-muted-foreground text-center">
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
</p>
</div>
</div>
@@ -1102,8 +1094,8 @@ export function HangingItems({
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
}}
onClick={() => handleItemClick(item, itemX)}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
title={item.name}
aria-label={`${item.name}. Click to release.`}
>
{/* Item emoji */}
<span
@@ -1114,24 +1106,6 @@ export function HangingItems({
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md"
)}
style={{
minWidth: HANGING_CONFIG.badgeSize,
height: HANGING_CONFIG.badgeSize,
padding: '0 5px',
}}
>
{item.quantity}
</span>
</button>
</div>
);
+1 -1
View File
@@ -76,10 +76,10 @@ export { useBlobbiItemUse } from './useBlobbiItemUse';
// Context
export {
BlobbiActionsContext,
BlobbiActionsProvider,
useBlobbiActions,
useBlobbiActionsRegistration,
} from './BlobbiActionsContext';
export { BlobbiActionsProvider } from './BlobbiActionsProvider';
// Components
export { CompanionActionMenu } from './CompanionActionMenu';
+2 -2
View File
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
/**
* Normalized item representation for the companion UI.
* This is a simplified view of inventory items optimized for rendering.
* This is a simplified view of shop catalog items optimized for rendering.
*/
export interface CompanionItem {
/** Unique item ID (matches shop item ID) */
@@ -74,7 +74,7 @@ export interface CompanionItem {
emoji: string;
/** Item category */
category: ShopItemCategory;
/** Quantity available in inventory */
/** Availability (always Infinity — items are reusable abilities) */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
@@ -27,13 +27,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
canUseItemForStage,
getStageRestrictionMessage,
@@ -59,7 +55,7 @@ import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
import type { UseItemFunction } from './BlobbiActionsProvider';
import type { UseItemFunction } from './BlobbiActionsContextDef';
// ─── Configuration ────────────────────────────────────────────────────────────
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const queryClient = useQueryClient();
// Fetch profile if not provided
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
const { profile: fetchedProfile } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
@@ -232,16 +228,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
// Core mutation for using items
// Core mutation for using items (always uses once)
const mutation = useMutation({
mutationFn: async ({
itemId,
action,
quantity = 1,
}: {
itemId: string;
action: InventoryAction;
quantity?: number;
}): Promise<{ statsChanged: Record<string, number> }> => {
// ─── Validation ───
if (!user?.pubkey) {
@@ -259,11 +253,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error('No companion selected');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -283,15 +272,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
@@ -319,17 +299,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects ───
// ─── Apply Item Effects (single use) ───
const isEggCompanion = companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = applyStat(currentHealth, healthDelta);
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
@@ -339,15 +315,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -362,11 +331,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -414,36 +380,19 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
updateCompanionInCache(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(profile.allTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
},
onSuccess: (_, { itemId, action, quantity = 1 }) => {
onSuccess: (_, { itemId, action }) => {
const shopItem = getShopItemById(itemId);
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
});
// Track daily mission progress
@@ -468,7 +417,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
// Wrapper function that matches UseItemFunction signature and includes cooldown check
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Check cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
@@ -481,7 +430,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
}
try {
const result = await mutation.mutateAsync({ itemId, action, quantity });
const result = await mutation.mutateAsync({ itemId, action });
return {
success: true,
statsChanged: result.statsChanged,
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
}
/**
* Resolve inventory items for a specific action/category.
* Resolve available items for a specific action/category from the shop catalog.
*
* Items are sourced from the full shop catalog — all items are
* available as reusable abilities/tools, filtered only by stage.
*
* Uses the centralized `canUseItemForStage` function to ensure consistent
* stage-based filtering across all UIs:
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
* filters out all egg-only items from the companion interaction system.
*/
function resolveItemsForAction(
storage: StorageItem[],
action: CompanionMenuAction,
stage: 'egg' | 'baby' | 'adult'
): CompanionItem[] {
@@ -89,13 +90,10 @@ function resolveItemsForAction(
// Sleep action has no items
if (!category) return [];
const allItems = getLiveShopItems();
const items: CompanionItem[] = [];
for (const storageItem of storage) {
if (storageItem.quantity <= 0) continue;
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== category) continue;
// Use centralized stage-based filtering
@@ -104,17 +102,17 @@ function resolveItemsForAction(
// - Food/Toys: only for baby/adult (excluded for eggs)
// - Medicine: must have health effect
// - Hygiene: must have hygiene or happiness effect
const usability = canUseItemForStage(storageItem.itemId, stage);
const usability = canUseItemForStage(shopItem.id, stage);
if (!usability.canUse) {
continue;
}
items.push({
id: storageItem.itemId,
id: shopItem.id,
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: storageItem.quantity,
quantity: Infinity,
effect: shopItem.effect,
});
}
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
return;
}
// Resolve items for this action
const items = resolveItemsForAction(profile.storage, action, stage);
// Resolve items for this action from the catalog (not inventory)
const items = resolveItemsForAction(action, stage);
setMenuState(prev => ({
...prev,
@@ -42,7 +42,6 @@ export interface ItemUseResult {
export type UseItemCallback = (
itemId: string,
action: InventoryAction,
quantity: number
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
/**
@@ -67,14 +66,14 @@ export interface UseCompanionItemUseResult {
isUsingItem: boolean;
/** Get the action type for an item category */
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
/** Get the inventory action for a menu action */
/** Get the care action for a menu action */
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* Map item categories to inventory actions.
* Map item categories to care actions.
* This is the canonical mapping for how items are used.
*/
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
@@ -85,14 +84,14 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
};
/**
* Map menu actions to inventory actions (they match by design).
* Map menu actions to item-based care actions (they match by design).
*/
export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, InventoryAction | null> = {
feed: 'feed',
play: 'play',
medicine: 'medicine',
clean: 'clean',
sleep: null, // Sleep is a special action, not an inventory action
sleep: null, // Sleep is a special action, not item-based
};
// ─── Hook Implementation ──────────────────────────────────────────────────────
@@ -108,8 +107,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
* Usage:
* ```tsx
* const { useItem, isUsingItem } = useCompanionItemUse({
* onUseItem: async (itemId, action, qty) => {
* return await executeUseItem({ itemId, action, quantity: qty });
* onUseItem: async (itemId, action) => {
* return await executeUseItem({ itemId, action });
* },
* onSuccess: (result) => removeItemFromScreen(result.item),
* onFailure: (result) => keepItemOnScreen(result.item),
@@ -134,7 +133,7 @@ export function useCompanionItemUse({
}, []);
/**
* Get the inventory action for a menu action.
* Get the care action for a menu action.
*/
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
@@ -187,7 +186,7 @@ export function useCompanionItemUse({
try {
// Execute the use callback
const useResult = await onUseItem(item.id, inventoryAction, 1);
const useResult = await onUseItem(item.id, inventoryAction);
if (useResult.success) {
const result: ItemUseResult = {
+2 -2
View File
@@ -288,7 +288,7 @@ export interface BlobbiCompanion {
}
/**
* Stored item in user's profile inventory
* Stored item in user's profile (from purchases)
*/
export interface StorageItem {
itemId: string; // Must match a ShopItem.id
@@ -316,7 +316,7 @@ export interface BlobbonautProfile {
coins: number;
/** Petting level (interaction counter) */
pettingLevel: number;
/** Purchased items inventory */
/** Purchased items storage */
storage: StorageItem[];
/** All tags preserved for republishing */
allTags: string[][];
+1 -1
View File
@@ -9,7 +9,7 @@ import { Theater } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useEmotionDev } from './EmotionDevContext';
import { useEmotionDev } from './useEmotionDev';
import { isLocalhostDev } from './index';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
+2 -54
View File
@@ -10,26 +10,10 @@
* - Is purely for visual testing/debugging
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { useState, useCallback, type ReactNode } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -68,40 +52,4 @@ export function EmotionDevProvider({ children }: EmotionDevProviderProps) {
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
+2 -1
View File
@@ -35,5 +35,6 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
// Emotion testing tools
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
export { EmotionDevProvider } from './EmotionDevContext';
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
// ─── Hooks ────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
@@ -0,0 +1,137 @@
// src/blobbi/rooms/components/BlobbiCareRoom.tsx
/**
* BlobbiCareRoom — Hygiene, care, and medicine room.
*
* Side actions depend on the currently focused carousel item:
* - Hygiene focused: Towel (left) + Shower (right)
* - Medicine focused: Treat (left) + spacer (right)
*
* Both left and right slots always render the same fixed width
* so the bottom bar never shifts when switching item types.
*/
import { useMemo, useState, useCallback } from 'react';
import { ShowerHead, Candy } from 'lucide-react';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiCareRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) {
const {
isUsingItem,
usingItemId,
handleUseItemFromTab,
isPublishing,
actionInProgress,
isActiveFloatingCompanion,
} = ctx;
const hygieneItems = useMemo(() =>
getLiveShopItems().filter(i => i.type === 'hygiene'),
[]);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const towelItem = hygieneItems.find(i => i.id === 'hyg_towel');
// Carousel: hygiene (except towel) + medicine, each tagged with meta
const carouselEntries = useMemo<CarouselEntry[]>(() => {
const hygiene = getLiveShopItems()
.filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'hygiene' }));
const medicine = getLiveShopItems()
.filter(i => i.type === 'medicine')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'medicine' }));
return [...hygiene, ...medicine];
}, []);
// Track the type of the currently focused carousel item
const [focusedMeta, setFocusedMeta] = useState<string>(
carouselEntries[0]?.meta ?? 'hygiene',
);
const handleFocusChange = useCallback((entry: CarouselEntry) => {
setFocusedMeta(entry.meta ?? 'hygiene');
}, []);
const isHygieneFocused = focusedMeta === 'hygiene';
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left slot — always same width (button or spacer) */}
{isHygieneFocused ? (
towelItem ? (
<RoomActionButton
icon={<span className="text-2xl sm:text-3xl">{towelItem.icon}</span>}
label="Towel"
color="text-cyan-500"
glowHex="#06b6d4"
onClick={() => handleUseItemFromTab(towelItem.id)}
disabled={isDisabled}
loading={isUsingItem && usingItemId === towelItem.id}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)
) : (
<RoomActionButton
icon={<Candy className="size-7 sm:size-9" />}
label="Treat"
color="text-pink-400"
glowHex="#f472b6"
onClick={() => {
// Comfort treat — use a small food item as a reward after medicine
const treat = getLiveShopItems().find(i => i.type === 'food');
if (treat) handleUseItemFromTab(treat.id);
}}
disabled={isDisabled}
/>
)}
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselEntries}
onUse={handleUseItemFromTab}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
onFocusChange={handleFocusChange}
/>
</div>
{/* Right slot — always same width (button or spacer) */}
{isHygieneFocused ? (
<RoomActionButton
icon={<ShowerHead className="size-7 sm:size-9" />}
label="Shower"
color="text-blue-500"
glowHex="#3b82f6"
onClick={() => {
const shampoo = hygieneItems.find(i => i.id === 'hyg_shampoo');
if (shampoo) handleUseItemFromTab(shampoo.id);
}}
disabled={isDisabled}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,37 @@
// src/blobbi/rooms/components/BlobbiClosetRoom.tsx
/**
* BlobbiClosetRoom — Placeholder room for wardrobe / accessories.
*
* Uses the same bottom bar structure as other rooms for visual consistency,
* with a centered placeholder message.
*/
import { Shirt } from 'lucide-react';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
interface BlobbiClosetRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) {
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* Bottom bar — same structure as other rooms */}
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-center gap-2 py-1">
<Shirt className="size-5 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground/40 font-medium">
Closet coming soon
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,542 @@
// src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx
/**
* BlobbiHatcheryRoom — Incubation / evolution / progression room.
*
* Layout:
* - BlobbiRoomHero (Blobbi visual + stats)
* - Bottom center: main start/stop hatching or evolution button
* - Bottom right: quests/tasks button
* - Bottom left: Blobbis list/selector button
*
* Reuses existing hatch/evolve/missions logic from BlobbiPage.
*/
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
Loader2, Sparkles, Egg, Target, Check, ListTodo,
Wrench, Droplets, Heart, Zap, Moon, Camera, Music, Mic,
Pill, Utensils, Plus, Footprints, ExternalLink, Theater,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { openUrl } from '@/lib/downloadFile';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { isLocalhostDev } from '@/blobbi/dev';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
// ─── Helper: companionNeedsCare (reused from BlobbiPage) ──────────────────────
const CARE_THRESHOLD = 40;
function companionNeedsCare(companion: { stats: { hunger?: number; happiness?: number; hygiene?: number; health?: number } }): boolean {
const { stats } = companion;
return (
(stats.hunger !== undefined && stats.hunger < CARE_THRESHOLD) ||
(stats.happiness !== undefined && stats.happiness < CARE_THRESHOLD) ||
(stats.hygiene !== undefined && stats.hygiene < CARE_THRESHOLD) ||
(stats.health !== undefined && stats.health < CARE_THRESHOLD)
);
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiHatcheryRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) {
const {
companion,
companions,
selectedD,
profile,
isEgg,
isBaby,
isIncubating,
isEvolvingState,
canStartIncubation,
canStartEvolution,
isStartingIncubation,
isStartingEvolution,
isStoppingIncubation,
isStoppingEvolution,
isHatching,
isEvolving,
hatchTasks,
evolveTasks,
onStartIncubation,
onStartEvolution,
onStopIncubation,
onStopEvolution,
onEvolve,
setShowPostModal,
setShowHatchCeremony,
isActiveFloatingCompanion,
// Blobbi selector
onSelectBlobbi,
blobbiNaddr,
// Adoption
setShowAdoptionFlow,
// Daily missions
dailyMissions,
onClaimReward,
isClaimingReward,
// DEV
setShowDevEditor,
setShowEmotionPanel,
} = ctx;
const navigate = useNavigate();
// Side panels
const [showQuestsPanel, setShowQuestsPanel] = useState(false);
const [showBlobbisPanel, setShowBlobbisPanel] = useState(false);
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
const tasks = isIncubating ? hatchTasks.tasks : evolveTasks.tasks;
const allCompleted = isIncubating ? hatchTasks.allCompleted : evolveTasks.allCompleted;
const isTasksLoading = isIncubating ? hatchTasks.isLoading : evolveTasks.isLoading;
const completedCount = tasks.filter(t => t.completed).length;
const totalCount = tasks.length;
const { missions } = dailyMissions;
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Bottom Action Bar ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Blobbis selector */}
<RoomActionButton
icon={<Egg className="size-7 sm:size-9" />}
label="Blobbis"
color="text-primary"
glowHex="var(--primary)"
onClick={() => setShowBlobbisPanel(true)}
badge={companions.length > 1 ? (
<span className="size-4 sm:size-5 rounded-full bg-primary text-[9px] sm:text-[10px] text-primary-foreground font-bold flex items-center justify-center">
{companions.length}
</span>
) : undefined}
/>
{/* Center — Main hatch/evolve action */}
<div className="flex-1 flex flex-col items-center justify-center gap-1.5">
{/* Active process: Hatch/Evolve CTA or progress */}
{hasActiveProcess && allCompleted && !isTasksLoading && (
<button
onClick={isIncubating ? () => setShowHatchCeremony(true) : onEvolve}
disabled={isProcessBusy}
className={cn(
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isProcessBusy && 'opacity-50 pointer-events-none',
)}
style={{
background: isIncubating
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
}}
>
{(isHatching || isEvolving) ? (
<Loader2 className="size-5 animate-spin" />
) : (
<span className="text-lg">{isIncubating ? '\uD83D\uDC23' : '\u2728'}</span>
)}
<span>{(isHatching || isEvolving) ? (isIncubating ? 'Hatching...' : 'Evolving...') : (isIncubating ? 'Hatch!' : 'Evolve!')}</span>
</button>
)}
{hasActiveProcess && !allCompleted && !isTasksLoading && (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="size-4 text-primary" />
<span className="font-medium">{isIncubating ? 'Hatching' : 'Evolving'}</span>
<span className="text-xs tabular-nums">{completedCount}/{totalCount}</span>
</div>
{/* Progress bar */}
<div className="w-40 h-1.5 rounded-full bg-muted/30 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
background: isIncubating
? 'linear-gradient(90deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(90deg, #8b5cf6, #ec4899)',
}}
/>
</div>
</div>
)}
{hasActiveProcess && isTasksLoading && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
)}
{/* No active process — show start button */}
{!hasActiveProcess && (canStartIncubation || canStartEvolution) && (
<button
onClick={() => canStartIncubation ? onStartIncubation('start') : onStartEvolution()}
disabled={isStartingIncubation || isStartingEvolution}
className={cn(
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
(isStartingIncubation || isStartingEvolution) && 'opacity-50 pointer-events-none',
)}
style={{
background: canStartIncubation
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
}}
>
{(isStartingIncubation || isStartingEvolution) ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Sparkles className="size-5" />
)}
<span>{canStartIncubation ? 'Begin Hatching' : 'Begin Evolution'}</span>
</button>
)}
{!hasActiveProcess && !canStartIncubation && !canStartEvolution && (
<p className="text-xs text-muted-foreground/50">No journey available</p>
)}
{/* Stop process link */}
{hasActiveProcess && !isTasksLoading && (
<button
onClick={isIncubating ? onStopIncubation : onStopEvolution}
disabled={isProcessBusy}
className="text-[11px] text-muted-foreground/40 hover:text-destructive/60 transition-colors"
>
{(isStoppingIncubation || isStoppingEvolution) ? 'Stopping...' : `Stop ${isIncubating ? 'incubation' : 'evolution'}`}
</button>
)}
</div>
{/* Right — Quests/Tasks */}
<RoomActionButton
icon={<ListTodo className="size-7 sm:size-9" />}
label="Quests"
color="text-amber-500"
glowHex="#f59e0b"
onClick={() => setShowQuestsPanel(true)}
badge={hasActiveProcess && totalCount - completedCount > 0 ? (
<span className="size-4 sm:size-5 rounded-full bg-amber-500 text-[9px] sm:text-[10px] text-white font-bold flex items-center justify-center">
{totalCount - completedCount}
</span>
) : undefined}
/>
</div>
</div>
)}
{/* ── Quests Sheet ── */}
<Sheet open={showQuestsPanel} onOpenChange={setShowQuestsPanel}>
<SheetContent side="right" className="w-80 sm:w-96 p-0">
<SheetHeader className="px-4 pt-4 pb-3 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<Target className="size-4" />
Quests
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="p-4 space-y-4">
{/* Journey tasks */}
{hasActiveProcess && (
<div className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{isIncubating ? 'Hatching Journey' : 'Evolution Journey'}
</h3>
{isTasksLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isTasksLoading && tasks.map(task => {
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate': navigate(task.actionTarget); setShowQuestsPanel(false); break;
case 'external_link': openUrl(task.actionTarget); break;
case 'open_modal': if (task.actionTarget === 'blobbi_post') { setShowPostModal(true); setShowQuestsPanel(false); } break;
}
};
const isActionable = !task.completed && !!task.action && !!task.actionTarget;
return (
<button
key={task.id}
onClick={isActionable ? handleAction : undefined}
disabled={!isActionable}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all text-left',
isActionable && 'hover:bg-accent/50 active:scale-[0.98] cursor-pointer',
!isActionable && 'cursor-default',
)}
>
<QuestTaskIcon taskId={task.id} completed={task.completed} />
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium leading-tight', task.completed && 'text-muted-foreground line-through')}>{task.name}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-1">{task.description}</p>
</div>
{task.required > 1 && !task.completed && (
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{task.current}/{task.required}</span>
)}
</button>
);
})}
</div>
)}
{!hasActiveProcess && (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-6 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Start a journey to unlock tasks</p>
</div>
)}
{/* Daily Bounties */}
<div className="space-y-1">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Daily Bounties
</h3>
{dailyMissions.noMissionsAvailable && (
<div className="flex flex-col items-center gap-2 py-4 text-center">
<Egg className="size-5 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Hatch your Blobbi to unlock bounties</p>
</div>
)}
{!dailyMissions.noMissionsAvailable && missions.map(mission => {
const canClaim = mission.completed && !mission.claimed;
return (
<div
key={mission.id}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all',
canClaim && 'bg-amber-500/[0.06]',
)}
>
<DailyMissionIcon action={mission.action} claimed={mission.claimed} canClaim={canClaim} />
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium leading-tight', mission.claimed && 'text-muted-foreground line-through')}>{mission.title}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{mission.description}</p>
</div>
{!mission.claimed && (
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{mission.currentCount}/{mission.requiredCount}</span>
)}
{canClaim && (
<button
onClick={() => onClaimReward(mission.id)}
disabled={isClaimingReward}
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
>
Claim
</button>
)}
</div>
);
})}
{/* Bonus row */}
{!dailyMissions.noMissionsAvailable && dailyMissions.bonusAvailable && !dailyMissions.bonusClaimed && (
<div className="w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl bg-amber-500/[0.06]">
<div className="size-8 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Sparkles className="size-4 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">Daily Champion</p>
<p className="text-[10px] text-muted-foreground">All missions complete!</p>
</div>
<button
onClick={() => onClaimReward('bonus_daily_complete')}
disabled={isClaimingReward}
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
>
Claim
</button>
</div>
)}
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
{/* ── Blobbis Sheet ── */}
<Sheet open={showBlobbisPanel} onOpenChange={setShowBlobbisPanel}>
<SheetContent side="left" className="w-80 sm:w-96 p-0">
<SheetHeader className="px-4 pt-4 pb-3 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<Egg className="size-4" />
Your Blobbis
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-4rem)]">
<div className="p-4">
{/* Blobbi grid */}
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 py-3">
{companions.map((c) => {
const isSelected = c.d === selectedD;
const isCompanion = c.d === profile?.currentCompanion;
return (
<button
key={c.d}
onClick={() => { onSelectBlobbi(c.d); setShowBlobbisPanel(false); }}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-200',
'hover:-translate-y-1 hover:scale-105 active:scale-95',
)}
>
<div className="relative">
<div className={cn(
'rounded-full p-1 transition-all',
isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
)}>
<BlobbiStageVisual companion={c} size="sm" />
</div>
{isCompanion && (
<div className="absolute -bottom-0.5 -right-0.5 size-5 rounded-full bg-background ring-2 ring-background flex items-center justify-center">
<Footprints className="size-3 text-emerald-500" />
</div>
)}
{companionNeedsCare(c) && !isCompanion && (
<div className="absolute -top-0.5 -right-0.5 size-4 rounded-full bg-amber-500 flex items-center justify-center">
<span className="text-[8px] text-white font-bold">!</span>
</div>
)}
</div>
{c.stage !== 'egg' && (
<span className={cn(
'text-[11px] font-medium max-w-[4.5rem] truncate',
isSelected ? 'text-foreground' : 'text-muted-foreground',
)}>
{c.name}
</span>
)}
</button>
);
})}
{/* Adopt + button */}
<button
onClick={() => { setShowBlobbisPanel(false); setShowAdoptionFlow(true); }}
className="flex flex-col items-center gap-1 transition-all duration-200 hover:-translate-y-1 hover:scale-105 active:scale-95"
>
<div className="size-14 rounded-full flex items-center justify-center" style={{
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, currentColor 10%, transparent), color-mix(in srgb, currentColor 3%, transparent) 70%)',
}}>
<Plus className="size-6 text-muted-foreground/60" />
</div>
<span className="text-[11px] font-medium text-muted-foreground/60">Adopt</span>
</button>
</div>
{/* Quick actions row */}
<div className="flex items-center justify-center gap-6 pt-3 border-t mt-3">
<Link
to={`/${blobbiNaddr}`}
onClick={() => setShowBlobbisPanel(false)}
className="flex flex-col items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="size-5" />
<span className="text-[10px]">View</span>
</Link>
{/* DEV tools */}
{isLocalhostDev() && (
<>
{companion.stage !== 'adult' && (
<button
onClick={() => { setShowBlobbisPanel(false); if (isEgg) { setShowHatchCeremony(true); } else { onEvolve(); } }}
disabled={isHatching || isEvolving}
className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors disabled:opacity-40"
>
<Sparkles className="size-5" />
<span className="text-[10px]">{companion.stage === 'egg' ? 'Hatch' : 'Evolve'}</span>
</button>
)}
<button onClick={() => { setShowBlobbisPanel(false); setShowDevEditor(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
<Wrench className="size-5" />
<span className="text-[10px]">Editor</span>
</button>
<button onClick={() => { setShowBlobbisPanel(false); setShowEmotionPanel(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
<Theater className="size-5" />
<span className="text-[10px]">Emote</span>
</button>
</>
)}
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
);
}
// ─── Quest task icon (reused from BlobbiPage) ─────────────────────────────────
function QuestTaskIcon({ taskId, completed }: { taskId: string; completed: boolean }) {
const iconClass = 'size-4';
const icon = (() => {
switch (taskId) {
case 'create_themes': return <Sparkles className={iconClass} />;
case 'color_moments': return <Droplets className={iconClass} />;
case 'create_posts': return <Target className={iconClass} />;
case 'interactions': return <Heart className={iconClass} />;
case 'edit_profile': return <Wrench className={iconClass} />;
case 'maintain_stats': return <Zap className={iconClass} />;
default: return <Target className={iconClass} />;
}
})();
return (
<div className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
completed ? 'bg-emerald-500/15 text-emerald-500' : 'bg-muted/60 text-muted-foreground',
)}>
{completed ? <Check className="size-4" /> : icon}
</div>
);
}
// ─── Daily mission icon (reused from BlobbiPage) ──────────────────────────────
function DailyMissionIcon({ action, claimed, canClaim }: { action: string; claimed: boolean; canClaim: boolean }) {
const iconClass = 'size-4';
const icon = (() => {
switch (action) {
case 'interact': return <Heart className={iconClass} />;
case 'feed': return <Utensils className={iconClass} />;
case 'clean': return <Droplets className={iconClass} />;
case 'sleep': return <Moon className={iconClass} />;
case 'take_photo': return <Camera className={iconClass} />;
case 'sing': return <Mic className={iconClass} />;
case 'play_music': return <Music className={iconClass} />;
case 'medicine': return <Pill className={iconClass} />;
default: return <Target className={iconClass} />;
}
})();
return (
<div className={cn(
'size-8 rounded-full flex items-center justify-center shrink-0',
claimed ? 'bg-emerald-500/15 text-emerald-500' : canClaim ? 'bg-amber-500/15 text-amber-500' : 'bg-muted/60 text-muted-foreground',
)}>
{claimed ? <Check className="size-4" /> : icon}
</div>
);
}
@@ -0,0 +1,162 @@
// src/blobbi/rooms/components/BlobbiHomeRoom.tsx
/**
* BlobbiHomeRoom — The main living / play room.
*
* Layout:
* - BlobbiRoomHero (stats crown, Blobbi visual, name)
* - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right)
* - Inline activity (music player, sing card) above the bottom bar
*
* Sleep/wake has been moved to BlobbiRestRoom.
*/
import { useMemo } from 'react';
import { Camera, Footprints, Music, Mic } from 'lucide-react';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiHomeRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
const {
isActiveFloatingCompanion,
setShowPhotoModal,
isCurrentCompanion,
canBeCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
isUsingItem,
usingItemId,
handleUseItemFromTab,
handleDirectAction,
isDirectActionPending,
inlineActivity,
handleConfirmSing,
handleCloseInlineActivity,
handleMusicPlaybackStart,
handleMusicPlaybackStop,
handleSingRecordingStart,
handleSingRecordingStop,
handleChangeTrack,
isPublishing,
actionInProgress,
} = ctx;
// Build carousel entries: toys + music + sing
const carouselItems = useMemo<CarouselEntry[]>(() => {
const toys = getLiveShopItems()
.filter(i => i.type === 'toy')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name }));
const actions: CarouselEntry[] = [
{
id: '__action_music',
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-pink-500/15 text-pink-500"><Music className="size-5 sm:size-6" /></div>,
label: 'Music',
},
{
id: '__action_sing',
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-purple-500/15 text-purple-500"><Mic className="size-5 sm:size-6" /></div>,
label: 'Sing',
},
];
return [...toys, ...actions];
}, []);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const handleCarouselUse = (id: string) => {
if (id === '__action_music') {
handleDirectAction('play_music');
} else if (id === '__action_sing') {
handleDirectAction('sing');
} else {
handleUseItemFromTab(id);
}
};
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero (Blobbi + stats) ── */}
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* ── Inline Activity Area (music/sing) ── */}
{inlineActivity.type === 'music' && (
<div className="px-4 sm:px-6 pb-2">
<InlineMusicPlayer
selection={inlineActivity.selection}
onChangeTrack={handleChangeTrack}
onClose={handleCloseInlineActivity}
onPlaybackStart={handleMusicPlaybackStart}
onPlaybackStop={handleMusicPlaybackStop}
isPublished={inlineActivity.isPublished}
isPublishing={isDirectActionPending}
/>
</div>
)}
{inlineActivity.type === 'sing' && (
<div className="px-4 sm:px-6 pb-2">
<InlineSingCard
onConfirm={handleConfirmSing}
onClose={handleCloseInlineActivity}
onRecordingStart={handleSingRecordingStart}
onRecordingStop={handleSingRecordingStop}
isPublishing={isDirectActionPending}
/>
</div>
)}
{/* ── Unified Bottom Bar: Photo | Carousel | Companion ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Photo */}
<RoomActionButton
icon={<Camera className="size-7 sm:size-9" />}
label="Photo"
color="text-pink-500"
glowHex="#ec4899"
onClick={() => setShowPhotoModal(true)}
/>
{/* Center carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={carouselItems}
onUse={handleCarouselUse}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
</div>
{/* Companion toggle */}
{canBeCompanion ? (
<RoomActionButton
icon={<Footprints className="size-7 sm:size-9" />}
label={isCurrentCompanion ? 'With you' : 'Take along'}
color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'}
glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'}
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
loading={isUpdatingCompanion}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
// src/blobbi/rooms/components/BlobbiKitchenRoom.tsx
/**
* BlobbiKitchenRoom — The feeding room.
*
* Bottom bar: Shovel (left, when poop exists) | food carousel (center) | Fridge (right)
* Poop appears at pre-computed safe positions in the lower corners.
*/
import { useMemo, useState } from 'react';
import { Refrigerator, Shovel } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { BlobbiActionInventoryModal } from '@/blobbi/actions/components/BlobbiActionInventoryModal';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { getPoopsInRoom, hasAnyPoop } from '../lib/poop-system';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
interface BlobbiKitchenRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiKitchenRoom({ ctx, poopState }: BlobbiKitchenRoomProps) {
const {
companion,
profile,
isUsingItem,
usingItemId,
handleUseItemFromTab,
isPublishing,
actionInProgress,
isActiveFloatingCompanion,
} = ctx;
const [showFridge, setShowFridge] = useState(false);
const foodEntries = useMemo<CarouselEntry[]>(() =>
getLiveShopItems()
.filter(i => i.type === 'food')
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name })),
[]);
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
const handleFridgeUseItem = (itemId: string) => {
if (isUsingItem) return;
ctx.onUseItem(itemId, 'feed').finally(() => {
setShowFridge(false);
});
};
const kitchenPoops = getPoopsInRoom(poopState.poops, 'kitchen');
const anyPoopAnywhere = hasAnyPoop(poopState.poops);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* ── Hero + Poop layer ── */}
<div className="relative flex-1 min-h-0 flex flex-col">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{/* Poop at pre-computed safe positions */}
{kitchenPoops.map((poop) => (
<button
key={poop.id}
onClick={() => poopState.shovelMode && poopState.onRemovePoop(poop.id)}
className={cn(
'absolute z-10 transition-all duration-300',
poopState.shovelMode
? 'cursor-pointer hover:scale-150 active:scale-75'
: 'pointer-events-none',
)}
style={{
bottom: `${poop.position.bottom}%`,
left: `${poop.position.left}%`,
}}
>
<span className={cn(
'text-2xl sm:text-3xl block',
poopState.shovelMode && 'drop-shadow-lg',
)}>
{'💩'}
</span>
</button>
))}
</div>
{/* ── Bottom bar ── */}
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-between gap-1 sm:gap-3">
{/* Left — Shovel (when poop exists) or spacer */}
{anyPoopAnywhere ? (
<RoomActionButton
icon={<Shovel className="size-7 sm:size-9" />}
label={poopState.shovelMode ? 'Done' : 'Shovel'}
color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'}
glowHex={poopState.shovelMode ? '#d97706' : '#78716c'}
onClick={() => poopState.setShovelMode(prev => !prev)}
className={poopState.shovelMode ? 'ring-2 ring-amber-500/40 ring-offset-2 ring-offset-background rounded-full' : ''}
/>
) : (
<div className="w-14 sm:w-20 shrink-0" />
)}
{/* Center: food carousel */}
<div className="flex-1 min-w-0 flex justify-center">
<ItemCarousel
items={foodEntries}
onUse={handleUseItemFromTab}
activeItemId={isUsingItem ? usingItemId : null}
disabled={isDisabled}
/>
</div>
{/* Right — Fridge */}
<RoomActionButton
icon={<Refrigerator className="size-7 sm:size-9" />}
label="Fridge"
color="text-orange-500"
glowHex="#f97316"
onClick={() => setShowFridge(true)}
disabled={isDisabled}
/>
</div>
</div>
)}
{showFridge && (
<BlobbiActionInventoryModal
open={showFridge}
onOpenChange={setShowFridge}
action="feed"
companion={companion}
profile={profile}
onUseItem={handleFridgeUseItem}
onOpenShop={() => setShowFridge(false)}
isUsingItem={isUsingItem}
usingItemId={usingItemId}
/>
)}
</div>
);
}
@@ -0,0 +1,62 @@
// src/blobbi/rooms/components/BlobbiRestRoom.tsx
/**
* BlobbiRestRoom — The bedroom / rest room.
*
* Bottom bar: Sleep/Wake button centered.
*/
import { Moon, Sun, Loader2 } from 'lucide-react';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
import { BlobbiRoomHero } from './BlobbiRoomHero';
import { RoomActionButton } from './RoomActionButton';
interface BlobbiRestRoomProps {
ctx: BlobbiRoomContext;
poopState: RoomPoopState;
}
export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) {
const {
isEgg,
isSleeping,
onRest,
actionInProgress,
isPublishing,
isUsingItem,
isActiveFloatingCompanion,
} = ctx;
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
return (
<div className="flex flex-col flex-1 min-h-0">
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
{!isActiveFloatingCompanion && (
<div className={ROOM_BOTTOM_BAR_CLASS}>
<div className="flex items-center justify-center">
{!isEgg && (
<RoomActionButton
icon={
actionInProgress === 'rest'
? <Loader2 className="size-7 sm:size-9 animate-spin" />
: isSleeping
? <Sun className="size-7 sm:size-9" />
: <Moon className="size-7 sm:size-9" />
}
label={isSleeping ? 'Wake up' : 'Sleep'}
color={isSleeping ? 'text-amber-500' : 'text-violet-500'}
glowHex={isSleeping ? '#f59e0b' : '#8b5cf6'}
onClick={onRest}
disabled={isDisabled}
/>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,284 @@
// src/blobbi/rooms/components/BlobbiRoomHero.tsx
/**
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
*
* This component does NOT clip or constrain the visual — it simply fills
* available flex space and centers the Blobbi + stats within it.
* The room owns the full-height surface; this just provides content.
*
* Top padding accounts for the floating room header overlay (~40px).
*/
import { useMemo } from 'react';
import {
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
Footprints, Loader2,
} from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import { cn } from '@/lib/utils';
import type { BlobbiRoomContext } from '../lib/room-types';
// ─── Stat colour maps ─────────────────────────────────────────────────────────
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
hunger: 'orange',
happiness: 'yellow',
health: 'green',
hygiene: 'blue',
energy: 'violet',
};
const STAT_COLORS: Record<string, string> = {
orange: 'text-orange-500',
yellow: 'text-yellow-500',
green: 'text-green-500',
blue: 'text-blue-500',
violet: 'text-violet-500',
};
const STAT_BG_COLORS: Record<string, string> = {
orange: 'bg-orange-500/10',
yellow: 'bg-yellow-500/10',
green: 'bg-green-500/10',
blue: 'bg-blue-500/10',
violet: 'bg-violet-500/10',
};
const STAT_RING_HEX: Record<string, string> = {
orange: '#f97316',
yellow: '#eab308',
green: '#22c55e',
blue: '#3b82f6',
violet: '#8b5cf6',
};
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
hunger: Utensils,
happiness: Gamepad2,
health: Heart,
hygiene: Droplets,
energy: Zap,
};
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiRoomHeroProps {
ctx: BlobbiRoomContext;
className?: string;
hideStats?: boolean;
hideName?: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRoomHeroProps) {
const {
companion,
currentStats,
isSleeping,
isEgg,
statusRecipe,
statusRecipeLabel,
effectiveEmotion,
hasDevOverride,
blobbiReaction,
isActiveFloatingCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
heroRef,
heroWidth,
} = ctx;
if (isActiveFloatingCompanion) {
return (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
<Footprints className="size-12 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
{companion.name} is out exploring right now.
</p>
<button
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
className={cn(
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isUpdatingCompanion && 'opacity-50 pointer-events-none',
)}
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
>
{isUpdatingCompanion ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Footprints className="size-4" />
)}
<span>Bring {companion.name} home</span>
</button>
</div>
);
}
return (
<div
ref={heroRef}
className={cn(
// No overflow-hidden — let the room own the visual surface.
// pt-10 creates clearance for the floating room header overlay.
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
className,
)}
>
<div className="relative flex flex-col items-center">
{/* Stats crown */}
{!hideStats && <StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />}
{/* Blobbi visual */}
<div
className="relative transition-all duration-500"
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}
>
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
reaction={blobbiReaction}
recipe={hasDevOverride ? undefined : statusRecipe}
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
className={isEgg
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
}
/>
</div>
{/* Blobbi Name */}
{!hideName && !isEgg && (
<h2
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
style={{ color: companion.visualTraits.baseColor }}
>
{companion.name}
</h2>
)}
</div>
</div>
);
}
// ─── Stats Crown ──────────────────────────────────────────────────────────────
function StatsCrown({
companion,
currentStats,
heroWidth,
}: {
companion: BlobbiRoomContext['companion'];
currentStats: BlobbiRoomContext['currentStats'];
heroWidth: number;
}) {
const allStats = useMemo(() =>
getVisibleStats(companion.stage).map(stat => ({
stat,
value: currentStats[stat] ?? 100,
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
color: STAT_COLOR_MAP[stat],
})),
[companion.stage, currentStats]);
if (allStats.length === 0) return null;
const count = allStats.length;
const isSmall = heroWidth < 400;
// Balanced arc: mobile is compact, desktop has moderate breathing room.
// These values produce a stable crown with no dramatic changes between
// 375px (mobile) and 640px+ (desktop) — a smooth interpolation.
const arcSpread = isSmall
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
const arcHalf = arcSpread / 2;
const angles = count === 1
? [0]
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
return (
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
{allStats.map((s, i) => {
const angleDeg = angles[i];
const angleRad = (angleDeg * Math.PI) / 180;
// Smooth interpolation: 110px at 340px width → 200px at 640px+ width
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
const x = Math.sin(angleRad) * radius;
const y = Math.cos(angleRad) * radius - radius;
return (
<div
key={s.stat}
className="absolute transition-all duration-500"
style={{
transform: `translate(-50%, 0)`,
left: `calc(50% + ${x.toFixed(1)}px)`,
bottom: `${y.toFixed(1)}px`,
}}
>
<StatIndicator
stat={s.stat}
value={s.value}
color={s.color}
status={s.status}
/>
</div>
);
})}
</div>
);
}
// ─── Stat Indicator ───────────────────────────────────────────────────────────
interface StatIndicatorProps {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
status?: 'normal' | 'warning' | 'critical';
}
function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorProps) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
return (
<div className={cn(
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
)}>
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
strokeWidth={3}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,201 @@
// src/blobbi/rooms/components/BlobbiRoomShell.tsx
/**
* BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard.
*
* Manages:
* - Current room state + navigation
* - Sleep dark overlay (scoped to this shell only)
* - Ephemeral poop instances (local-only, no persistence)
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from '@/hooks/useToast';
import {
type BlobbiRoomId,
ROOM_META,
DEFAULT_ROOM_ORDER,
DEFAULT_INITIAL_ROOM,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from '../lib/room-config';
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
import {
generateInitialPoops,
removePoop,
type PoopInstance,
} from '../lib/poop-system';
import { BlobbiHomeRoom } from './BlobbiHomeRoom';
import { BlobbiKitchenRoom } from './BlobbiKitchenRoom';
import { BlobbiCareRoom } from './BlobbiCareRoom';
import { BlobbiHatcheryRoom } from './BlobbiHatcheryRoom';
import { BlobbiRestRoom } from './BlobbiRestRoom';
import { BlobbiClosetRoom } from './BlobbiClosetRoom';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiRoomShellProps {
ctx: BlobbiRoomContext;
roomOrder?: BlobbiRoomId[];
initialRoom?: BlobbiRoomId;
}
interface RoomNavState {
current: BlobbiRoomId;
direction: 'left' | 'right' | null;
}
// ─── Room Component Map ───────────────────────────────────────────────────────
const ROOM_COMPONENTS: Record<BlobbiRoomId, React.ComponentType<{ ctx: BlobbiRoomContext; poopState: RoomPoopState }>> = {
care: BlobbiCareRoom,
kitchen: BlobbiKitchenRoom,
home: BlobbiHomeRoom,
hatchery: BlobbiHatcheryRoom,
rest: BlobbiRestRoom,
closet: BlobbiClosetRoom,
};
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomShell({
ctx,
roomOrder = DEFAULT_ROOM_ORDER,
initialRoom = DEFAULT_INITIAL_ROOM,
}: BlobbiRoomShellProps) {
const [nav, setNav] = useState<RoomNavState>({
current: roomOrder.includes(initialRoom) ? initialRoom : roomOrder[0],
direction: null,
});
const goRight = useCallback(() => {
setNav(prev => ({
current: getNextRoom(prev.current, roomOrder),
direction: 'right',
}));
}, [roomOrder]);
const goLeft = useCallback(() => {
setNav(prev => ({
current: getPreviousRoom(prev.current, roomOrder),
direction: 'left',
}));
}, [roomOrder]);
const meta = ROOM_META[nav.current];
const roomIndex = getRoomIndex(nav.current, roomOrder);
const RoomComponent = ROOM_COMPONENTS[nav.current];
const dots = useMemo(() => roomOrder.map((id, i) => ({
id,
active: i === roomIndex,
label: ROOM_META[id].label,
})), [roomOrder, roomIndex]);
const isSleeping = ctx.isSleeping;
// ─── Poop system (ephemeral, local-only) ───
const [poops, setPoops] = useState<PoopInstance[]>([]);
const [shovelMode, setShovelMode] = useState(false);
// Generate poop on mount
useEffect(() => {
const hunger = ctx.currentStats.hunger;
const lastFeed = ctx.lastFeedTimestamp ?? ctx.companion.lastInteraction * 1000;
setPoops(generateInitialPoops(hunger, lastFeed));
// Only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onRemovePoop = useCallback((poopId: string) => {
setPoops(prev => {
const { remaining, xpReward } = removePoop(prev, poopId);
if (xpReward > 0) {
toast({ title: `+${xpReward} XP`, description: 'Cleaned up!' });
}
if (remaining.length === 0) {
setShovelMode(false);
}
return remaining;
});
}, []);
const poopState: RoomPoopState = useMemo(() => ({
poops,
shovelMode,
setShovelMode,
onRemovePoop,
}), [poops, shovelMode, onRemovePoop]);
return (
<div className="flex flex-col flex-1 min-h-0 relative">
{/* ── Room Content — fills the entire shell ── */}
<div className="flex-1 min-h-0 flex flex-col relative">
<RoomComponent ctx={ctx} poopState={poopState} />
</div>
{/* ── Sleep overlay — darkens the room when Blobbi sleeps ── */}
{isSleeping && (
<div
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
/>
)}
{/* ── Floating Room Header ── */}
<div className="absolute inset-x-0 top-0 z-30 pointer-events-none">
<div className="flex flex-col items-center pt-2 pb-1">
<div className="flex items-center gap-1.5 pointer-events-auto">
<span className="text-sm">{meta.icon}</span>
<span className="text-xs sm:text-sm font-semibold text-foreground/70">{meta.label}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{dots.map(dot => (
<div
key={dot.id}
className={cn(
'rounded-full transition-all duration-300',
dot.active
? 'w-4 h-1 bg-primary'
: 'w-1 h-1 bg-muted-foreground/20',
)}
title={dot.label}
/>
))}
</div>
</div>
</div>
{/* ── Left / Right Navigation Arrows ── */}
<button
onClick={goLeft}
className={cn(
'absolute left-0.5 top-1/2 -translate-y-1/2 z-40',
'size-9 rounded-full flex items-center justify-center',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
)}
aria-label={`Go to ${ROOM_META[getPreviousRoom(nav.current, roomOrder)].label}`}
>
<ChevronLeft className="size-5" />
</button>
<button
onClick={goRight}
className={cn(
'absolute right-0.5 top-1/2 -translate-y-1/2 z-40',
'size-9 rounded-full flex items-center justify-center',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
)}
aria-label={`Go to ${ROOM_META[getNextRoom(nav.current, roomOrder)].label}`}
>
<ChevronRight className="size-5" />
</button>
</div>
);
}
@@ -0,0 +1,166 @@
// src/blobbi/rooms/components/ItemCarousel.tsx
/**
* ItemCarousel — Single-focus carousel for room items.
*
* Layout stability guarantees:
* - The entire carousel width is deterministic (arrows + previews + focus slot)
* - Focused item uses a fixed-size container with overflow-hidden
* - Label is clamped to a fixed max-width and single line
* - Switching items never causes reflow or arrow movement
*
* Mobile: focused item only + compact arrows (no prev/next previews)
* Desktop: focused item + translucent prev/next previews + arrows
*/
import { useState, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CarouselEntry {
id: string;
/** Emoji string or ReactNode rendered at large size */
icon: React.ReactNode;
label: string;
/** Optional metadata attached to the entry (e.g. item type) */
meta?: string;
}
interface ItemCarouselProps {
items: CarouselEntry[];
/** Called when the user taps the focused item */
onUse: (id: string) => void;
/** Item id currently being used (shows spinner) */
activeItemId?: string | null;
/** Whether any action is in progress */
disabled?: boolean;
/** Called when the focused item changes (for conditional side actions) */
onFocusChange?: (entry: CarouselEntry) => void;
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ItemCarousel({
items,
onUse,
activeItemId,
disabled,
onFocusChange,
className,
}: ItemCarouselProps) {
const [index, setIndex] = useState(0);
const count = items.length;
const prev = useCallback(() => {
setIndex(i => {
const n = (i - 1 + count) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
const next = useCallback(() => {
setIndex(i => {
const n = (i + 1) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
if (count === 0) {
return (
// Empty state matches the height of a populated carousel
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
</div>
);
}
const current = items[index];
const prevItem = items[(index - 1 + count) % count];
const nextItem = items[(index + 1) % count];
const isThisActive = activeItemId === current.id;
const showPreviews = count >= 3;
return (
<div className={cn('flex items-center justify-center', className)}>
{/* Left arrow — fixed 28/32px */}
<button
onClick={prev}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Previous item"
>
<ChevronLeft className="size-4" />
</button>
{/* Preview (prev) — desktop only, fixed 40x48px slot */}
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{prevItem.icon}</span>
</div>
</div>
)}
{/* Focused item — FIXED 80x72 / 96x88 container, never resizes */}
<button
onClick={() => onUse(current.id)}
disabled={disabled}
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',
'transition-colors duration-200',
'hover:bg-accent/20 active:scale-95',
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
)}
>
<span className="text-4xl sm:text-5xl leading-none">
{current.icon}
</span>
{/* Label: fixed max-width, single line, ellipsis */}
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
{current.label}
</span>
{isThisActive && (
<Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />
)}
</button>
{/* Preview (next) — desktop only, fixed 40x48px slot */}
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{nextItem.icon}</span>
</div>
</div>
)}
{/* Right arrow — fixed 28/32px */}
<button
onClick={next}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Next item"
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
@@ -0,0 +1,79 @@
// src/blobbi/rooms/components/RoomActionButton.tsx
/**
* RoomActionButton — Unified circular action button for all rooms.
*
* Responsive sizing:
* - Mobile: size-14 circle, size-7 icons
* - Desktop (sm+): size-20 circle, size-9 icons
*
* Matches the soft radial glow of the original Photo / Companion buttons
* but at a smaller scale so the bottom bar feels proportional on mobile.
*/
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface RoomActionButtonProps {
/** Lucide icon or emoji element rendered inside the circle */
icon: React.ReactNode;
/** Small text label below the circle */
label: string;
/** CSS colour class applied to the icon (e.g. 'text-pink-500') */
color: string;
/** Hex colour used for the radial glow background */
glowHex: string;
onClick: () => void;
disabled?: boolean;
loading?: boolean;
/** Optional badge content rendered at top-right of the circle */
badge?: React.ReactNode;
className?: string;
}
export function RoomActionButton({
icon,
label,
color,
glowHex,
onClick,
disabled,
loading,
badge,
className,
}: RoomActionButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
'hover:-translate-y-1 hover:scale-110 active:scale-95',
disabled && 'opacity-50 pointer-events-none',
className,
)}
>
<div className="relative">
<div
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
style={{
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
}}
>
{loading ? (
<Loader2 className="size-7 sm:size-9 animate-spin" />
) : (
icon
)}
</div>
{badge && (
<div className="absolute -top-0.5 -right-0.5">
{badge}
</div>
)}
</div>
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
// src/blobbi/rooms/index.ts — barrel export
export {
type BlobbiRoomId,
type BlobbiRoomMeta,
ROOM_META,
DEFAULT_ROOM_ORDER,
DEFAULT_INITIAL_ROOM,
getNextRoom,
getPreviousRoom,
getRoomIndex,
} from './lib/room-config';
export { BlobbiRoomShell } from './components/BlobbiRoomShell';
export { BlobbiHomeRoom } from './components/BlobbiHomeRoom';
export { BlobbiKitchenRoom } from './components/BlobbiKitchenRoom';
export { BlobbiCareRoom } from './components/BlobbiCareRoom';
export { BlobbiHatcheryRoom } from './components/BlobbiHatcheryRoom';
export { BlobbiRestRoom } from './components/BlobbiRestRoom';
export { BlobbiClosetRoom } from './components/BlobbiClosetRoom';
+137
View File
@@ -0,0 +1,137 @@
// src/blobbi/rooms/lib/poop-system.ts
/**
* Temporary local-only poop system.
*
* Generates poop based on:
* A) Overfeeding: hunger >= 95 -> poop in kitchen
* B) Time elapsed: every 2 hours since last feed -> poop in a random room
*
* This is entirely ephemeral -- no persistence to Nostr or localStorage.
* The state is generated fresh on page load and managed in React state.
*/
import type { BlobbiRoomId } from './room-config';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PoopInstance {
id: string;
room: BlobbiRoomId;
/** 'overfeed' poops are kitchen-only and disappear on room change */
source: 'overfeed' | 'time';
/** Timestamp when this poop was generated */
createdAt: number;
/**
* Safe-zone position for this poop.
* Kept as % offsets so the layout stays responsive.
* Positions are in the lower-left and lower-right corners,
* avoiding the central Blobbi hero area.
*/
position: { bottom: number; left: number };
}
// ─── Constants ────────────────────────────────────────────────────────────────
const OVERFEED_THRESHOLD = 95;
const HOURS_PER_POOP = 2;
const XP_PER_POOP = 5;
/** Rooms where time-based poop can appear (not closet) */
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'hatchery', 'rest'];
/**
* Pre-defined safe positions in the lower corners of the room.
* Values are percentages. These avoid the central hero area
* (roughly 30%70% horizontal, above 35% vertical).
*/
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
{ bottom: 22, left: 8 }, // lower-left
{ bottom: 18, left: 78 }, // lower-right
{ bottom: 28, left: 14 }, // mid-left
{ bottom: 25, left: 82 }, // mid-right
{ bottom: 15, left: 20 }, // bottom-left-ish
{ bottom: 20, left: 72 }, // bottom-right-ish
];
// ─── Generation ───────────────────────────────────────────────────────────────
let _idCounter = 0;
function nextPoopId(): string {
return `poop_${++_idCounter}_${Date.now()}`;
}
function pickPosition(index: number): { bottom: number; left: number } {
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
}
/**
* Generate initial poop instances based on current companion state.
* Called once when the dashboard mounts.
*/
export function generateInitialPoops(
hunger: number,
lastFeedTimestamp: number | undefined,
): PoopInstance[] {
const poops: PoopInstance[] = [];
const now = Date.now();
let posIndex = 0;
// A) Overfeeding poop -- kitchen only
if (hunger >= OVERFEED_THRESHOLD) {
poops.push({
id: nextPoopId(),
room: 'kitchen',
source: 'overfeed',
createdAt: now,
position: pickPosition(posIndex++),
});
}
// B) Time-based poop -- random room
if (lastFeedTimestamp) {
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
const poopCount = Math.floor(hoursSinceFeed / HOURS_PER_POOP);
for (let i = 0; i < Math.min(poopCount, 3); i++) {
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
poops.push({
id: nextPoopId(),
room,
source: 'time',
createdAt: now - i * 1000,
position: pickPosition(posIndex++),
});
}
}
return poops;
}
/**
* Get poops visible in a specific room.
*/
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
return poops.filter(p => p.room === room);
}
/**
* Remove a poop by id and return the XP reward.
*/
export function removePoop(
poops: PoopInstance[],
poopId: string,
): { remaining: PoopInstance[]; xpReward: number } {
const remaining = poops.filter(p => p.id !== poopId);
const wasRemoved = remaining.length < poops.length;
return {
remaining,
xpReward: wasRemoved ? XP_PER_POOP : 0,
};
}
/**
* Check if any poop exists anywhere.
*/
export function hasAnyPoop(poops: PoopInstance[]): boolean {
return poops.length > 0;
}
+144
View File
@@ -0,0 +1,144 @@
// src/blobbi/rooms/lib/room-config.ts
/**
* Blobbi Room System — Configuration & Navigation
*
* This module defines the room types, default ordering, and navigation helpers.
* The design supports future per-user customisation: the default order is data,
* not hardcoded control flow, so it can be replaced with a user-stored sequence.
*/
// ─── Room IDs ─────────────────────────────────────────────────────────────────
/**
* Unique identifier for each room in the Blobbi world.
* New rooms can be added here without breaking existing code.
*/
export type BlobbiRoomId = 'care' | 'kitchen' | 'home' | 'hatchery' | 'rest' | 'closet';
// ─── Room Metadata ────────────────────────────────────────────────────────────
export interface BlobbiRoomMeta {
/** Unique room identifier */
id: BlobbiRoomId;
/** Human-readable display label */
label: string;
/** Short description (for tooltips / accessibility) */
description: string;
/** Emoji icon representing the room */
icon: string;
}
/**
* Static metadata for every room.
* This is a lookup — order does NOT matter here.
*/
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
care: {
id: 'care',
label: 'Care Room',
description: 'Hygiene, care, and medicine',
icon: '🩹',
},
kitchen: {
id: 'kitchen',
label: 'Kitchen',
description: 'Feed your Blobbi',
icon: '🍳',
},
home: {
id: 'home',
label: 'Home',
description: 'Main living room',
icon: '🏠',
},
hatchery: {
id: 'hatchery',
label: 'Hatchery',
description: 'Evolution and quests',
icon: '🥚',
},
rest: {
id: 'rest',
label: 'Bedroom',
description: 'Rest and recharge',
icon: '🌙',
},
closet: {
id: 'closet',
label: 'Closet',
description: 'Wardrobe and accessories',
icon: '👗',
},
};
// ─── Default Room Order ───────────────────────────────────────────────────────
/**
* The default room sequence.
*
* IMPORTANT: This array is the ONLY place that defines order.
* To support per-user customisation later, replace this with
* a user-stored array of BlobbiRoomId values.
*
* Closet is excluded for now (not yet implemented).
* To re-enable, add 'closet' back to the array.
*/
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
'care',
'kitchen',
'home',
'hatchery',
'rest',
// 'closet', — re-enable when wardrobe feature is ready
];
/**
* The room that should be selected when the dashboard first loads.
*/
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
// ─── Navigation Helpers ───────────────────────────────────────────────────────
/**
* Get the next room in a looping sequence.
*
* @param current - The currently active room
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
* @returns The next room id (wraps around)
*/
export function getNextRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[0];
return order[(idx + 1) % order.length];
}
/**
* Get the previous room in a looping sequence.
*
* @param current - The currently active room
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
* @returns The previous room id (wraps around)
*/
export function getPreviousRoom(
current: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): BlobbiRoomId {
const idx = order.indexOf(current);
if (idx === -1) return order[order.length - 1];
return order[(idx - 1 + order.length) % order.length];
}
/**
* Get the index of a room in the order array.
* Returns -1 if the room is not in the order.
*/
export function getRoomIndex(
room: BlobbiRoomId,
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
): number {
return order.indexOf(room);
}
+15
View File
@@ -0,0 +1,15 @@
// src/blobbi/rooms/lib/room-layout.ts
/**
* Shared layout constants for Blobbi room components.
*/
/**
* CSS class for the bottom action bar in every room.
*
* On mobile/tablet (max-sidebar), adds extra bottom padding so the
* room controls clear the app's fixed bottom navigation bar.
* On desktop (sidebar:), uses normal padding since there's no bottom nav.
*/
export const ROOM_BOTTOM_BAR_CLASS =
'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]';
+194
View File
@@ -0,0 +1,194 @@
// src/blobbi/rooms/lib/room-types.ts
/**
* Shared prop types for Blobbi room components.
*
* These types are the "contract" that the BlobbiDashboard passes down
* to each room. They mirror the existing BlobbiDashboard internal state
* so rooms can reuse all existing logic without duplication.
*/
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
import type {
InventoryAction,
DirectAction,
InlineActivityState,
BlobbiReactionState,
SelectedTrack,
StartIncubationMode,
} from '@/blobbi/actions';
import type { useHatchTasks, useEvolveTasks, useDailyMissions } from '@/blobbi/actions';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { ShopItem } from '@/blobbi/shop/types/shop.types';
// ─── Shared Dashboard Context ─────────────────────────────────────────────────
/**
* Everything a room needs from the dashboard.
* Passed down by BlobbiRoomShell so rooms don't import dashboard state directly.
*/
export interface BlobbiRoomContext {
// ── Core data ──
companion: BlobbiCompanion;
companions: BlobbiCompanion[];
selectedD: string;
profile: BlobbonautProfile | null;
// ── Projected / visual state ──
currentStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
isSleeping: boolean;
isEgg: boolean;
isBaby: boolean;
// ── Visual recipe ──
statusRecipe: BlobbiVisualRecipe | undefined;
statusRecipeLabel: string | undefined;
effectiveEmotion: BlobbiEmotion;
hasDevOverride: boolean;
blobbiReaction: BlobbiReactionState;
// ── Item use ──
onUseItem: (itemId: string, action: InventoryAction) => Promise<void>;
handleUseItemFromTab: (itemId: string) => void;
isUsingItem: boolean;
usingItemId: string | null;
allShopItems: ShopItem[];
// ── Direct actions ──
onDirectAction: (action: DirectAction) => Promise<void>;
handleDirectAction: (action: DirectAction) => void;
isDirectActionPending: boolean;
// ── Inline activity (music/sing) ──
inlineActivity: InlineActivityState;
setInlineActivity: React.Dispatch<React.SetStateAction<InlineActivityState>>;
setBlobbiReaction: React.Dispatch<React.SetStateAction<BlobbiReactionState>>;
setActionOverrideEmotion: React.Dispatch<React.SetStateAction<BlobbiEmotion | null>>;
showTrackPickerModal: boolean;
setShowTrackPickerModal: React.Dispatch<React.SetStateAction<boolean>>;
handleTrackSelected: (selection: SelectedTrack) => Promise<void>;
handleConfirmSing: () => Promise<void>;
handleCloseInlineActivity: () => void;
handleMusicPlaybackStart: () => void;
handleMusicPlaybackStop: () => void;
handleSingRecordingStart: () => void;
handleSingRecordingStop: () => void;
handleChangeTrack: () => void;
// ── Rest / sleep ──
onRest: () => void;
actionInProgress: string | null;
isPublishing: boolean;
// ── Companion toggle ──
isCurrentCompanion: boolean;
canBeCompanion: boolean;
isUpdatingCompanion: boolean;
isActiveFloatingCompanion: boolean;
handleSetAsCompanion: () => Promise<void>;
// ── Photo ──
showPhotoModal: boolean;
setShowPhotoModal: React.Dispatch<React.SetStateAction<boolean>>;
// ── Blobbi selector ──
onSelectBlobbi: (d: string) => void;
// ── Incubation / Evolution / Tasks ──
isIncubating: boolean;
isEvolvingState: boolean;
canStartIncubation: boolean;
canStartEvolution: boolean;
isStartingIncubation: boolean;
isStartingEvolution: boolean;
isStoppingIncubation: boolean;
isStoppingEvolution: boolean;
isHatching: boolean;
isEvolving: boolean;
hatchTasks: ReturnType<typeof useHatchTasks>;
evolveTasks: ReturnType<typeof useEvolveTasks>;
onStartIncubation: (mode: StartIncubationMode, stopOtherD?: string) => Promise<void>;
onStartEvolution: () => Promise<void>;
onStopIncubation: () => Promise<void>;
onStopEvolution: () => Promise<void>;
onHatch: () => Promise<void>;
onEvolve: () => Promise<void>;
showPostModal: boolean;
setShowPostModal: React.Dispatch<React.SetStateAction<boolean>>;
refetchCurrentTasks: () => void;
// ── Daily missions ──
dailyMissions: ReturnType<typeof useDailyMissions>;
onClaimReward: (id: string) => void;
isClaimingReward: boolean;
availableStages: ('egg' | 'baby' | 'adult')[];
// ── Adoption ──
showAdoptionFlow: boolean;
setShowAdoptionFlow: React.Dispatch<React.SetStateAction<boolean>>;
// ── Adoption + Profile update props ──
publishEvent: (params: { kind: number; content: string; tags: string[][] }) => Promise<NostrEvent>;
updateProfileEvent: (event: NostrEvent) => void;
updateCompanionEvent: (event: NostrEvent) => void;
invalidateProfile: () => void;
invalidateCompanion: () => void;
setStoredSelectedD: (d: string) => void;
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: StorageItem[];
} | null>;
// ── Naddr link ──
blobbiNaddr: string;
// ── Hero measurement ──
/** Callback ref for the hero container — re-attaches ResizeObserver on room switch */
heroRef: React.RefCallback<HTMLDivElement> | React.RefObject<HTMLDivElement | null>;
heroWidth: number;
// ── DEV ONLY ──
showDevEditor: boolean;
setShowDevEditor: (show: boolean) => void;
onDevEditorApply: (updates: import('@/blobbi/dev').BlobbiDevUpdates) => Promise<void>;
isDevUpdating: boolean;
showEmotionPanel: boolean;
setShowEmotionPanel: React.Dispatch<React.SetStateAction<boolean>>;
showHatchCeremony: boolean;
setShowHatchCeremony: React.Dispatch<React.SetStateAction<boolean>>;
// ── Inventory modal (still used in kitchen) ──
inventoryAction: InventoryAction | null;
setInventoryAction: React.Dispatch<React.SetStateAction<InventoryAction | null>>;
// ── Last feed timestamp (for poop system) ──
lastFeedTimestamp: number | undefined;
}
// ─── Poop State (passed from shell to rooms) ──────────────────────────────────
import type { PoopInstance } from './poop-system';
export interface RoomPoopState {
/** All poop instances across rooms */
poops: PoopInstance[];
/** Whether shovel mode is currently active */
shovelMode: boolean;
/** Toggle shovel mode on/off */
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
/** Remove a poop (returns XP reward via callback) */
onRemovePoop: (poopId: string) => void;
}
@@ -1,17 +1,15 @@
import { useMemo, useState } from 'react';
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
@@ -20,7 +18,7 @@ import {
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn } from '@/lib/utils';
import { ItemEffectDisplay } from './ItemEffectDisplay';
@@ -31,228 +29,169 @@ interface BlobbiInventoryModalProps {
profile: BlobbonautProfile | null;
/** The current companion (needed for stage-based restrictions) */
companion: BlobbiCompanion | null;
/** Called when user wants to use an item. Opens the use flow. */
onUseItem?: (itemId: string, quantity: number) => void;
/** Called when user wants to use an item. Always uses once. */
onUseItem?: (itemId: string) => void;
/** Whether an item is currently being used */
isUsingItem?: boolean;
}
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
// ── Shared items content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile,
profile: _profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryContentProps) {
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showUseDialog, setShowUseDialog] = useState(false);
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
const isEmpty = inventoryItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowUseDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseUseDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
onUseItem(item.itemId);
};
return (
<>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
</p>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for your Blobbi's current stage.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Quantity Badge */}
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
×{item.quantity}
</Badge>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleSelectItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
Use
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
<InventoryUseConfirmDialog
open={showUseDialog}
onOpenChange={handleCloseUseDialog}
item={selectedItem}
companion={companion}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleUseItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
{isUsingItem ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</>
</div>
);
}
@@ -298,153 +237,3 @@ export function BlobbiInventoryModal({
</Dialog>
);
}
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
interface InventoryUseConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function InventoryUseConfirmDialog({
open,
onOpenChange,
item,
companion,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: InventoryUseConfirmDialogProps) {
const totalEffect = useMemo(() => {
if (!item.effect) return null;
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const result: Record<string, number> = {};
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
result[stat] = delta;
}
}
return Object.keys(result).length > 0 ? result : null;
}, [item.effect, companion.stats, quantity]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>Use Item</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{totalEffect && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<ItemEffectDisplay effect={totalEffect} variant="badges" />
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15 -31
View File
@@ -16,17 +16,16 @@ import {
import type { ShopItem } from '../types/shop.types';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
@@ -39,7 +38,7 @@ interface BlobbiShopModalProps {
initialTab?: TopTab;
// ── Inventory props (passed through) ──
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
@@ -80,28 +79,24 @@ export function BlobbiShopModal({
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
// ── Inventory items resolution ──
// ── Items resolution — sourced from the full catalog (not inventory) ──
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allCatalogItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allCatalogItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
// ── Inventory use item handler ──
const [usingItemId, setUsingItemId] = useState<string | null>(null);
@@ -109,7 +104,7 @@ export function BlobbiShopModal({
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
setUsingItemId(item.itemId);
onUseItem(item.itemId, 1);
onUseItem(item.itemId);
};
// Clear usingItemId when isUsingItem goes false
@@ -138,7 +133,7 @@ export function BlobbiShopModal({
Items
{!inventoryEmpty && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
{inventoryItems.length}
</Badge>
)}
{topTab === 'items' && (
@@ -265,7 +260,7 @@ function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridPr
);
}
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
// ─── Items Grid (catalog, tile layout) ────────────────────────────────────────
interface ItemsGridProps {
items: ResolvedInventoryItem[];
@@ -275,20 +270,16 @@ interface ItemsGridProps {
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-8 text-muted-foreground/60" />
</div>
<p className="text-sm text-muted-foreground mb-4">
No items yet. Visit the shop to stock up!
<p className="text-sm text-muted-foreground">
No items are available for your Blobbi's current stage.
</p>
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
<ShoppingBag className="size-3.5" />
Browse Shop
</Button>
</div>
);
}
@@ -308,13 +299,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
)}
>
{/* Quantity badge */}
<Badge
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
>
{item.quantity}
</Badge>
{/* Icon */}
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
@@ -7,7 +7,7 @@
* Used by:
* - BlobbiShopItemRow (shop listing)
* - BlobbiPurchaseDialog (purchase confirmation)
* - BlobbiInventoryModal (inventory listing)
* - BlobbiInventoryModal (items listing)
* - BlobbiActionInventoryModal (action item selection)
*
* All consumers should use this component to ensure consistent display of item effects.
@@ -192,30 +192,6 @@ export function ItemEffectDisplay({
return null;
}
// ─── Utility Exports ──────────────────────────────────────────────────────────
/**
* Format effects as a summary string (for compatibility with existing code).
* This is a drop-in replacement for formatEffectSummary in blobbi-shop-utils.ts.
*
* @deprecated Use <ItemEffectDisplay variant="inline" /> instead
*/
export function formatEffectSummary(effect: ItemEffect | undefined, maxEffects = 4): string {
const entries = getSortedEffectEntries(effect);
if (entries.length === 0) {
return 'No effects';
}
const displayEntries = maxEffects !== undefined ? entries.slice(0, maxEffects) : entries;
return displayEntries
.map(([stat, value]) => `${formatStatValue(value)} ${STAT_LABELS[stat]}`)
.join(', ');
}
/**
* Get sorted effect entries for custom rendering.
* Useful when you need to iterate over effects yourself.
*/
export { getSortedEffectEntries };
+1 -1
View File
@@ -7,7 +7,7 @@
import { useMemo } from 'react';
import { Play, Pause, Music, ListMusic, Podcast, Clock } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
import { useAuthor } from '@/hooks/useAuthor';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
/**
* Auto-minimizes the audio player when the user navigates to a different page.
+1 -33
View File
@@ -3,39 +3,7 @@ import { Award } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCardTilt } from '@/hooks/useCardTilt';
/** Parsed NIP-58 badge definition data. */
export interface BadgeData {
identifier: string;
name: string;
description?: string;
image?: string;
imageDimensions?: string;
thumbs: Array<{ url: string; dimensions?: string }>;
}
/** Parse a kind 30009 badge definition event into structured data. */
export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
if (event.kind !== 30009) return null;
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
if (!identifier) return null;
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
const description = event.tags.find(([n]) => n === 'description')?.[1];
const imageTag = event.tags.find(([n]) => n === 'image');
const image = imageTag?.[1];
const imageDimensions = imageTag?.[2];
const thumbs: Array<{ url: string; dimensions?: string }> = [];
for (const tag of event.tags) {
if (tag[0] === 'thumb' && tag[1]) {
thumbs.push({ url: tag[1], dimensions: tag[2] });
}
}
return { identifier, name, description, image, imageDimensions, thumbs };
}
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
interface BadgeContentProps {
event: NostrEvent;
+1 -1
View File
@@ -28,7 +28,7 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { parseBadgeDefinition } from '@/components/BadgeContent';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
+2 -2
View File
@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
+1 -1
View File
@@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { cn } from '@/lib/utils';
interface BadgeDisplayItem {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { Award } from 'lucide-react';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { cn } from '@/lib/utils';
+2 -84
View File
@@ -2,8 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
import type { CoreThemeColors } from '@/themes';
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
import type { NostrEvent } from '@nostrify/nostrify';
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
@@ -12,12 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
export function getColors(tags: string[][]): string[] {
return tags
.filter(([n]) => n === 'c')
.map(([, v]) => v)
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
}
/** Compute a best-fit grid: cols × rows for n items. */
function gridDimensions(n: number): { cols: number; rows: number } {
@@ -193,82 +187,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
);
}
// ─── Palette → theme mapping ─────────────────────────────
function hexContrast(hex1: string, hex2: string): number {
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
}
function hexSaturation(hex: string): number {
return rgbToHsl(...hexToRgb(hex)).s;
}
/**
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
* contrast against `bgHsl`. Steps toward white or black depending on which
* direction gives better contrast. Returns the adjusted HSL string.
*/
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
const bg = parseHsl(bgHsl);
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
const { h, s, l } = parseHsl(hsl);
// Decide direction: go lighter if bg is dark, darker if bg is light
const goLighter = bgLum < 0.18;
let current = l;
for (let i = 0; i < 50; i++) {
current = goLighter
? Math.min(100, current + 2)
: Math.max(0, current - 2);
const rgb = hslToRgb(h, s, current);
const lum = getLuminance(...rgb);
const lighter = Math.max(bgLum, lum);
const darker = Math.min(bgLum, lum);
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
}
return formatHsl(h, s, current);
}
/**
* Map palette hex colors to CoreThemeColors with guaranteed readability:
* 1. background = darkest color
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
* 3. primary = most saturated remaining color; if contrast < 3:1 against
* background, adjust its lightness until it passes
*/
export function paletteToTheme(colors: string[]): CoreThemeColors {
if (colors.length === 0) {
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
}
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
const bgHex = sorted[0];
const bgHsl = hexToHslString(bgHex);
// Text: lightest palette color; override with white/black if contrast is too low
const textHex = sorted[sorted.length - 1];
let textHsl = hexToHslString(textHex);
if (hexContrast(textHex, bgHex) < 4.5) {
// Pick white or black — whichever contrasts better
const whiteContrast = hexContrast('#ffffff', bgHex);
const blackContrast = hexContrast('#000000', bgHex);
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
}
// Primary: most saturated of remaining colors; nudge lightness if needed
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
const pool = rest.length > 0 ? rest : [textHex];
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
let primaryHsl = hexToHslString(primaryHex);
if (hexContrast(primaryHex, bgHex) < 3) {
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
}
return { background: bgHsl, text: textHsl, primary: primaryHsl };
}
// ─── Main component ──────────────────────────────────────
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
+2 -2
View File
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
@@ -26,7 +26,7 @@ function getTag(tags: string[][], name: string): string | undefined {
// ── data hook ─────────────────────────────────────────────────────────────────
export function useEventComments(event: NostrEvent | undefined) {
function useEventComments(event: NostrEvent | undefined) {
const { nostr } = useNostr();
const aTag = event
+1
View File
@@ -1086,6 +1086,7 @@ export function ComposeBox({
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onPointerDown={expand}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
+180 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { IntroImage } from '@/components/IntroImage';
import {
Users, Download, Loader2, X, Pencil, Home, Globe,
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useToast } from '@/hooks/useToast';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useInterests } from '@/hooks/useInterests';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -24,7 +25,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useMuteList, type MuteListItem } from '@/hooks/useMuteList';
import { useAuthor } from '@/hooks/useAuthor';
import { FeedEditModal } from '@/components/FeedEditModal';
import { buildKindOptions } from '@/components/SavedFeedFiltersEditor';
import { buildKindOptions } from '@/lib/feedFilterUtils';
import { genUserName } from '@/lib/genUserName';
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
@@ -556,6 +557,183 @@ function FeedTabsSection() {
{/* Saved Feeds */}
<SavedFeedsSection />
{/* Interests (hashtag & geotag tabs) */}
<InterestsSection />
</div>
);
}
// ─── Interests Section ───────────────────────────────────────────────────────
function InterestsSection() {
const { toast } = useToast();
const { user } = useCurrentUser();
const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t');
const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g');
const [newHashtag, setNewHashtag] = useState('');
const [newGeotag, setNewGeotag] = useState('');
const isLoading = isLoadingHashtags || isLoadingGeotags;
if (!user) return null;
const handleRemoveHashtag = async (tag: string) => {
await removeHashtag.mutateAsync(tag);
toast({ title: `#${tag} removed from feed tabs` });
};
const handleRemoveGeotag = async (tag: string) => {
await removeGeotag.mutateAsync(tag);
toast({ title: `${tag} removed from feed tabs` });
};
const handleAddHashtag = async () => {
const tag = newHashtag.trim().toLowerCase().replace(/^#/, '');
if (!tag) return;
if (hashtags.includes(tag)) {
toast({ title: `#${tag} is already followed`, variant: 'destructive' });
return;
}
await addHashtag.mutateAsync(tag);
setNewHashtag('');
toast({ title: `#${tag} added to feed tabs` });
};
const handleAddGeotag = async () => {
const tag = newGeotag.trim().toLowerCase();
if (!tag) return;
if (geotags.includes(tag)) {
toast({ title: `${tag} is already followed`, variant: 'destructive' });
return;
}
await addGeotag.mutateAsync(tag);
setNewGeotag('');
toast({ title: `${tag} added to feed tabs` });
};
return (
<div className="px-3 py-4 space-y-4 border-t border-border">
<div>
<h3 className="text-sm font-semibold">Interest Tabs</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Hashtags and locations you follow appear as tabs on the home feed.
</p>
</div>
{/* Hashtags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Hashtags</span>
</div>
<div className="flex gap-2">
<Input
placeholder="ditto"
value={newHashtag}
onChange={(e) => setNewHashtag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
className="h-9"
disabled={addHashtag.isPending}
/>
<Button
onClick={handleAddHashtag}
disabled={addHashtag.isPending || !newHashtag.trim()}
size="sm"
className="h-9"
>
{addHashtag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : hashtags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed hashtags yet.</p>
) : (
<div className="space-y-1.5">
{hashtags.map((tag) => (
<div
key={`hashtag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<Hash className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveHashtag(tag)}
disabled={removeHashtag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove #${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Geotags */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Locations</span>
</div>
<div className="flex gap-2">
<Input
placeholder="geohash (e.g. u4pru)"
value={newGeotag}
onChange={(e) => setNewGeotag(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }}
className="h-9"
disabled={addGeotag.isPending}
/>
<Button
onClick={handleAddGeotag}
disabled={addGeotag.isPending || !newGeotag.trim()}
size="sm"
className="h-9"
>
{addGeotag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
</div>
) : geotags.length === 0 ? (
<p className="text-xs text-muted-foreground">No followed locations yet.</p>
) : (
<div className="space-y-1.5">
{geotags.map((tag) => (
<div
key={`geotag:${tag}`}
className="rounded-lg border border-border/50 bg-secondary/30"
>
<div className="flex items-center gap-2 py-2 px-2.5">
<MapPin className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
<button
onClick={() => handleRemoveGeotag(tag)}
disabled={removeGeotag.isPending}
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
aria-label={`Remove ${tag}`}
>
<X className="size-3.5" strokeWidth={4} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -9,9 +9,9 @@ import { getAvatarShape } from '@/lib/avatarShape';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
+1 -1
View File
@@ -26,7 +26,7 @@ export interface EmojiPackData {
}
/** Parse a kind 30030 emoji pack event into structured data. */
export function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
if (event.kind !== 30030) return null;
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
+6 -4
View File
@@ -332,14 +332,15 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
// For edit mode, fetch fresh event to preserve any tags we don't manage
let preservedTags: string[][] = [];
let prev: NostrEvent | null = null;
if (isEditMode) {
const fresh = await fetchFreshEvent(nostr, {
prev = await fetchFreshEvent(nostr, {
kinds: [30030],
authors: [user.pubkey],
'#d': [resolvedId],
});
if (fresh) {
preservedTags = fresh.tags.filter(
if (prev) {
preservedTags = prev.tags.filter(
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
);
}
@@ -357,7 +358,8 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
kind: 30030,
content: '',
tags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
prev: prev ?? undefined,
});
// Clean up blob URLs
for (const e of emojis) {
+1 -2
View File
@@ -18,14 +18,13 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
import {
buildKindOptions,
MultiKindPicker,
AuthorChip,
AuthorFilterDropdown,
ScopeToggle,
ListPackPicker,
parseSelectedKinds,
} from '@/components/SavedFeedFiltersEditor';
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
+1 -1
View File
@@ -17,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseBadgeDefinition } from '@/components/BadgeContent';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAwardBadge } from '@/hooks/useAwardBadge';
import { useToast } from '@/hooks/useToast';
+6 -5
View File
@@ -671,7 +671,7 @@ function ProfileStep({
);
if (validFields.length > 0)
data.fields = validFields.map((f) => [f.label, f.value]);
await publishEvent({ kind: 0, content: JSON.stringify(data) });
await publishEvent({ kind: 0, content: JSON.stringify(data), tags: [] });
queryClient.invalidateQueries({ queryKey: ["logins"] });
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
} catch {
@@ -949,15 +949,15 @@ function FollowsStep({
})
.catch((): NostrEvent[] => []);
const latestEvent =
const prev =
followEvents.length > 0
? followEvents.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest,
)
: null;
const existingFollows = latestEvent
? latestEvent.tags
const existingFollows = prev
? prev.tags
.filter(([name]) => name === "p")
.map(([, pk]) => pk)
: [];
@@ -966,8 +966,9 @@ function FollowsStep({
await publishEvent({
kind: 3,
content: latestEvent?.content ?? "",
content: prev?.content ?? "",
tags: allFollows.map((pk) => ["p", pk]),
prev: prev ?? undefined,
});
setFollowedPacks((prev) => new Set([...prev, packId]));
+45 -33
View File
@@ -155,16 +155,19 @@ function QuotesTab({ quotes }: { quotes: QuoteEntry[] }) {
/* ──── Reactions Tab ──── */
function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
// Group reactions by emoji
const grouped = useMemo(() => {
const groups = new Map<string, ReactionEntry[]>();
// Summary of unique emojis with counts, sorted by popularity
const emojiSummary = useMemo(() => {
const counts = new Map<string, { count: number; url?: string }>();
for (const r of reactions) {
const key = r.emoji;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(r);
const existing = counts.get(r.emoji);
if (existing) {
existing.count++;
} else {
counts.set(r.emoji, { count: 1, url: r.emojiUrl });
}
}
// Sort groups by count (most popular first)
return Array.from(groups.entries()).sort((a, b) => b[1].length - a[1].length);
return Array.from(counts.entries())
.sort((a, b) => b[1].count - a[1].count);
}, [reactions]);
if (reactions.length === 0) {
@@ -173,32 +176,31 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
return (
<div>
{grouped.map(([emoji, entries]) => {
// Check if this is a custom emoji — use the URL from the first entry
const firstEntry = entries[0];
const customUrl = firstEntry?.emojiUrl;
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
{/* Emoji summary bar */}
{emojiSummary.length > 1 && (
<div className="flex items-center gap-2 px-4 py-2.5 bg-secondary/30 border-b border-border flex-wrap">
{emojiSummary.map(([emoji, { count, url }]) => {
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
return (
<span key={emoji} className="inline-flex items-center gap-1 text-sm">
{url && customName ? (
<CustomEmojiImg name={customName} url={url} className="inline-block h-5 w-5 object-contain" />
) : (
<span>{emoji}</span>
)}
<span className="text-xs text-muted-foreground font-medium tabular-nums">{count}</span>
</span>
);
})}
</div>
)}
return (
<div key={emoji}>
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
{customUrl && customName ? (
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6 object-contain" />
) : (
<span className="text-lg">{emoji}</span>
)}
<span className="text-xs text-muted-foreground font-medium">{entries.length}</span>
</div>
{/* Users who reacted with this emoji — each row links to the reaction event */}
<div className="divide-y divide-border">
{entries.map((entry, i) => (
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
))}
</div>
</div>
);
})}
{/* Flat list — each row shows the emoji badge inline */}
<div className="divide-y divide-border">
{reactions.map((entry, i) => (
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
))}
</div>
</div>
);
}
@@ -275,6 +277,7 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(entry.pubkey);
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
const customName = isCustomEmoji(entry.emoji) ? entry.emoji.slice(1, -1) : undefined;
return (
<Link
@@ -302,6 +305,15 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
<span className="text-xs text-muted-foreground">{timeAgo(entry.createdAt)}</span>
</div>
{/* Reaction emoji badge */}
<div className="flex items-center justify-center shrink-0 bg-secondary/60 rounded-full size-8">
{entry.emojiUrl && customName ? (
<CustomEmojiImg name={customName} url={entry.emojiUrl} className="inline-block h-5 w-5 object-contain" />
) : (
<span className="text-base leading-none">{entry.emoji}</span>
)}
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
+1 -118
View File
@@ -19,48 +19,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { getContentWarning } from '@/lib/contentWarning';
// ── Media type detection ──────────────────────────────────────────────────────
export type MediaType = 'image' | 'video' | 'audio';
/** Event kinds that are inherently video content (vines, horizontal video, vertical video). */
const VIDEO_KINDS = new Set([34236, 21, 22]);
/** Event kinds that are inherently audio content (music tracks, podcast episodes/trailers). */
const AUDIO_KINDS = new Set([36787, 34139, 30054, 30055, 1222]);
function detectType(url: string, mime?: string, eventKind?: number): MediaType {
if (mime) {
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('image/')) return 'image';
}
if (/\.(mp4|webm|mov|qt|m3u8)(\?.*)?$/i.test(url)) return 'video';
if (/\.(mp3|ogg|flac|wav|aac|opus)(\?.*)?$/i.test(url)) return 'audio';
// Fall back to event kind for extensionless URLs (e.g. Blossom content-addressed URLs)
if (eventKind !== undefined) {
if (VIDEO_KINDS.has(eventKind)) return 'video';
if (AUDIO_KINDS.has(eventKind)) return 'audio';
}
return 'image';
}
// ── Aspect ratio helpers ──────────────────────────────────────────────────────
/** Default aspect ratio when dim tag is missing or unparseable. */
const DEFAULT_ASPECT_RATIO = 1;
/** Parse a dim string like "1280x720" into a width/height aspect ratio number. */
export function parseDimToAspectRatio(dim?: string): number {
if (!dim) return DEFAULT_ASPECT_RATIO;
const match = dim.match(/^(\d+)x(\d+)$/);
if (!match) return DEFAULT_ASPECT_RATIO;
const w = parseInt(match[1], 10);
const h = parseInt(match[2], 10);
if (!w || !h) return DEFAULT_ASPECT_RATIO;
return w / h;
}
import { parseDimToAspectRatio, eventToMediaItem, type MediaType, type MediaItem } from '@/lib/mediaUtils';
/** A row of items in the justified layout. */
interface JustifiedRow<T> {
@@ -127,82 +86,6 @@ function justifiedLayout<T>(
return { rows, lastRowIncomplete: false };
}
// ── Media extraction ──────────────────────────────────────────────────────────
export interface MediaItem {
url: string;
type: MediaType;
blurhash?: string;
dim?: string;
alt?: string;
mime?: string;
allUrls: string[];
allTypes: MediaType[];
allDims: (string | undefined)[];
event: NostrEvent;
hasMultiple: boolean;
/** NIP-36 content warning reason, or empty string if flagged with no reason, or undefined if clean. */
contentWarning?: string;
}
function parseImeta(tags: string[][]): { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] {
const results: { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] = [];
for (const tag of tags) {
if (tag[0] !== 'imeta') continue;
const parts: Record<string, string> = {};
for (let i = 1; i < tag.length; i++) {
const sp = tag[i].indexOf(' ');
if (sp !== -1) parts[tag[i].slice(0, sp)] = tag[i].slice(sp + 1);
}
if (parts.url) results.push({ url: parts.url, blurhash: parts.blurhash, dim: parts.dim, alt: parts.alt, mime: parts.m });
}
return results;
}
function extractMediaUrls(content: string): string[] {
return content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|qt|mp3|ogg|flac|wav|aac|opus)(\?[^\s]*)?/gi) ?? [];
}
export function eventToMediaItem(event: NostrEvent): MediaItem | null {
const imeta = parseImeta(event.tags);
const cw = getContentWarning(event);
if (imeta.length > 0) {
const first = imeta[0];
const firstType = detectType(first.url, first.mime, event.kind);
return {
url: first.url,
type: firstType,
blurhash: first.blurhash,
dim: first.dim,
alt: first.alt,
mime: first.mime,
allUrls: imeta.map((e) => e.url),
allTypes: imeta.map((e) => detectType(e.url, e.mime, event.kind)),
allDims: imeta.map((e) => e.dim),
event,
hasMultiple: imeta.length > 1,
contentWarning: cw,
};
}
if (event.kind === 1) {
const urls = extractMediaUrls(event.content);
if (urls.length > 0) {
const types = urls.map((u) => detectType(u));
return {
url: urls[0],
type: types[0],
allUrls: urls,
allTypes: types,
allDims: urls.map(() => undefined),
event,
hasMultiple: urls.length > 1,
contentWarning: cw,
};
}
}
return null;
}
// ── Flat entry — one per media URL across all events ─────────────────────────
interface FlatEntry {
-27
View File
@@ -3,33 +3,6 @@ import { Play, Pause } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatTime } from '@/lib/formatTime';
/** Audio file extensions used to detect audio URLs. */
const AUDIO_EXTENSIONS = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i;
/** Image file extensions used to detect image URLs. */
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i;
/** Video file extensions used to detect video URLs. */
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|qt)(\?.*)?$/i;
/** Check whether a URL points to an audio file by extension. */
export function isAudioUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return AUDIO_EXTENSIONS.test(url);
}
/** Check whether a URL points to an image file by extension. */
export function isImageUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return IMAGE_EXTENSIONS.test(url);
}
/** Check whether a URL points to a video file by extension. */
export function isVideoUrl(url: string): boolean {
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
return VIDEO_EXTENSIONS.test(url);
}
interface MiniAudioPlayerProps {
src: string;
label?: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Pause, SkipBack, SkipForward, Maximize2, X, GripVertical } from 'lucide-react';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { cn } from '@/lib/utils';
const POSITION_KEY = 'audio-minibar-position';
+1 -1
View File
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { NoteCard } from '@/components/NoteCard';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
+77 -136
View File
@@ -16,6 +16,7 @@ import {
Radio,
Share2,
SmilePlus,
PartyPopper,
Sparkles,
Users,
Zap,
@@ -104,6 +105,7 @@ import { isSingleImagePost } from "@/lib/noteContent";
import { shareOrCopy } from "@/lib/share";
import { timeAgo } from "@/lib/timeAgo";
import { formatNumber } from "@/lib/formatNumber";
import { publishedAtAction } from "@/lib/publishedAtAction";
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
import { cn } from "@/lib/utils";
@@ -1040,7 +1042,7 @@ export const NoteCard = memo(function NoteCard({
? isLive ? "text-primary" : "text-muted-foreground"
: cfg.iconClassName
}
action={typeof cfg.action === "function" ? cfg.action(event.tags, event) : cfg.action}
action={typeof cfg.action === "function" ? cfg.action(event) : cfg.action}
noun={cfg.noun}
nounRoute={cfg.nounRoute}
/>
@@ -1059,39 +1061,29 @@ export const NoteCard = memo(function NoteCard({
onAuxClick={handleAuxClick}
>
{threadedKindHeader}
{isFollowPack ? (
<div className={cn("min-w-0", threaded && "pb-3")}>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{avatarElement}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
{authorInfo}
{contentBlock}
<FollowPackAuthorLine pubkey={event.pubkey} createdAt={event.created_at} />
{actionButtons}
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</div>
) : (
<div className="flex gap-3">
<div className="flex flex-col items-center">
{avatarElement}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
{authorInfo}
{contentBlock}
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</div>
</div>
)}
</div>
</article>
);
}
@@ -1134,7 +1126,7 @@ export const NoteCard = memo(function NoteCard({
}
action={
typeof cfg.action === "function"
? cfg.action(event.tags, event)
? cfg.action(event)
: cfg.action
}
noun={cfg.noun}
@@ -1144,46 +1136,29 @@ export const NoteCard = memo(function NoteCard({
})()
)}
{/* For follow packs / lists: content-first layout with subtle author attribution */}
{isFollowPack ? (
<>
{contentBlock}
<FollowPackAuthorLine pubkey={event.pubkey} createdAt={event.created_at} />
{!compact && (
<>
{actionButtons}
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
</>
)}
</>
) : (
<>
{/* Header: avatar + name/handle stacked */}
<div className="flex items-center gap-3">
{avatarElement}
{authorInfo}
{isColor && <ColorMomentEyeButton event={event} />}
</div>
{/* Header: avatar + name/handle stacked */}
<div className="flex items-center gap-3">
{avatarElement}
{authorInfo}
{isColor && <ColorMomentEyeButton event={event} />}
</div>
{contentBlock}
{contentBlock}
{/* Action buttons — hidden in compact/embed mode */}
{!compact && (
<>
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</>
)}
{/* Action buttons — hidden in compact/embed mode */}
{!compact && (
<>
{actionButtons}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
<ReplyComposeModal
event={event}
open={replyOpen}
onOpenChange={setReplyOpen}
/>
</>
)}
</article>
@@ -1703,52 +1678,6 @@ function StreamContent({ event }: { event: NostrEvent }) {
);
}
/** Subtle author attribution line for follow pack / list cards. */
function FollowPackAuthorLine({ pubkey, createdAt }: { pubkey: string; createdAt: number }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
return (
<div className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground">
{author.isLoading ? (
<>
<Skeleton className="size-4 rounded-full shrink-0" />
<Skeleton className="h-3 w-20" />
</>
) : (
<>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-4">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[7px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={profileUrl}
className="hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</ProfileHoverCard>
<span className="shrink-0">·</span>
<span className="shrink-0">{timeAgo(createdAt)}</span>
</>
)}
</div>
);
}
export interface EventActionHeaderProps {
/** Pubkey of the person performing the action. */
pubkey: string;
@@ -1768,8 +1697,8 @@ export interface EventActionHeaderProps {
interface KindHeaderConfig {
icon: React.ComponentType<{ className?: string }>;
iconClassName?: string;
/** Static action string, or a function that computes it from the event's tags (and optionally the full event). */
action: string | ((tags: string[][], event?: NostrEvent) => string);
/** Static action string, or a function that computes it from the event. */
action: string | ((event: NostrEvent) => string);
noun?: string;
nounRoute?: string;
}
@@ -1794,7 +1723,7 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
37516: {
icon: ChestIcon,
action: "hid a",
action: (event) => publishedAtAction(event, { created: "hid a", updated: "updated a", fallback: "hid a" }),
noun: "treasure",
nounRoute: "/treasures",
},
@@ -1806,61 +1735,61 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
37381: {
icon: CardsIcon,
action: "shared a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "deck",
nounRoute: "/decks",
},
36767: {
icon: Sparkles,
action: "shared a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "theme",
nounRoute: "/themes",
},
16767: {
icon: Sparkles,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated their", fallback: "updated their" }),
noun: "theme",
nounRoute: "/themes",
},
30030: {
icon: SmilePlus,
action: "shared an",
action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }),
noun: "emoji pack",
nounRoute: "/emojis",
},
30009: {
icon: Award,
action: "created a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
noun: "badge",
nounRoute: "/badges",
},
10008: {
icon: Award,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "badges",
nounRoute: "/badges",
},
30008: {
icon: Award,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "badges",
nounRoute: "/badges",
},
30311: {
icon: Radio,
iconClassName: undefined, // computed dynamically below
action: (_tags, event) =>
event && getEffectiveStreamStatus(event) === "live"
action: (event) =>
getEffectiveStreamStatus(event) === "live"
? "is streaming"
: "streamed",
},
32267: {
icon: Package,
action: "published a Zapstore app",
action: (event) => publishedAtAction(event, { created: "published a Zapstore app", updated: "updated a Zapstore app", fallback: "published a Zapstore app" }),
},
30063: {
icon: Package,
action: "published a Zapstore release",
action: (event) => publishedAtAction(event, { created: "published a Zapstore release", updated: "updated a Zapstore release", fallback: "published a Zapstore release" }),
},
3063: {
icon: Package,
@@ -1868,11 +1797,11 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
31990: {
icon: Package,
action: "published an app",
action: (event) => publishedAtAction(event, { created: "published an app", updated: "updated an app", fallback: "published an app" }),
},
30617: {
icon: GitBranch,
action: "shared a",
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "repository",
nounRoute: "/development",
},
@@ -1890,19 +1819,19 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
30817: {
icon: FileCode,
action: "proposed a",
action: (event) => publishedAtAction(event, { created: "proposed a", updated: "updated a", fallback: "proposed a" }),
noun: "NIP",
nounRoute: "/development",
},
15128: {
icon: Rocket,
action: "deployed an",
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
noun: "nsite",
nounRoute: "/development",
},
35128: {
icon: Rocket,
action: "deployed an",
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
noun: "nsite",
nounRoute: "/development",
},
@@ -1912,10 +1841,22 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
31124: {
icon: Egg,
action: "updated their",
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
noun: "Blobbi",
nounRoute: "/blobbi",
},
39089: {
icon: PartyPopper,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "follow pack",
nounRoute: "/packs",
},
30000: {
icon: PartyPopper,
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
noun: "follow set",
nounRoute: "/packs",
},
};
/** Generic action header: icon · [author name] [action] [linked noun] */
+1 -1
View File
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { NoteCard } from '@/components/NoteCard';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
/** Format a full date. */
+2 -57
View File
@@ -7,68 +7,13 @@ import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { isProfileBadgesKind } from '@/lib/badgeUtils';
/** Maximum badges to show in the preview grid before truncating. */
const PREVIEW_LIMIT = 12;
/** A parsed badge reference from a profile badges event. */
interface BadgeRef {
/** The `a` tag value referencing a kind 30009 badge definition. */
aTag: string;
/** The `e` tag value referencing a kind 8 badge award event. */
eTag?: string;
/** Parsed components from the `a` tag. */
kind: number;
pubkey: string;
identifier: string;
}
/** Parse a profile badges event (kind 10008 or legacy 30008) into badge references. */
export function parseProfileBadges(event: NostrEvent): BadgeRef[] {
if (!isProfileBadgesKind(event.kind)) return [];
// Legacy kind 30008 requires d=profile_badges; kind 10008 doesn't need it
if (event.kind === 30008) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (dTag !== 'profile_badges') return [];
}
const refs: BadgeRef[] = [];
const tags = event.tags;
for (let i = 0; i < tags.length; i++) {
if (tags[i][0] === 'a' && tags[i][1]) {
const aTag = tags[i][1];
const parts = aTag.split(':');
if (parts.length < 3) continue;
const kind = parseInt(parts[0], 10);
if (kind !== 30009) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
// Look for the corresponding `e` tag immediately after
let eTag: string | undefined;
if (i + 1 < tags.length && tags[i + 1][0] === 'e') {
eTag = tags[i + 1][1];
}
refs.push({ aTag, eTag, kind, pubkey, identifier });
}
}
// Deduplicate by aTag — keep first occurrence only
const seen = new Set<string>();
return refs.filter((r) => {
if (seen.has(r.aTag)) return false;
seen.add(r.aTag);
return true;
});
}
interface ProfileBadgesContentProps {
event: NostrEvent;
}
+3 -2
View File
@@ -19,9 +19,10 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { getContentWarning } from '@/lib/contentWarning';
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
import { MiniAudioPlayer } from '@/components/MiniAudioPlayer';
import { isAudioUrl, isImageUrl, isVideoUrl } from '@/lib/mediaTypeDetection';
import { VideoPlayer } from '@/components/VideoPlayer';
import { parseDimToAspectRatio } from '@/components/MediaCollage';
import { parseDimToAspectRatio } from '@/lib/mediaUtils';
import { isWeatherFieldLabel } from '@/lib/weatherStation';
import { WeatherStationCard } from '@/components/WeatherStationCard';
+2 -3
View File
@@ -21,17 +21,16 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
import {
buildKindOptions,
MultiKindPicker,
ScopeToggle,
parseSelectedKinds,
AuthorChip,
AuthorFilterDropdown,
ListPackPicker,
} from '@/components/SavedFeedFiltersEditor';
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import type { ProfileTab, TabFilter } from '@/lib/profileTabsEvent';
+2 -2
View File
@@ -7,7 +7,7 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { ComposeBox } from '@/components/ComposeBox';
@@ -107,7 +107,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogContentRef}
className="max-w-[520px] max-h-[85vh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden flex flex-col"
className="max-w-[520px] max-h-[85dvh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden !flex !flex-col"
onOpenAutoFocus={(e) => {
e.preventDefault();
const target = e.target as HTMLElement;
+4 -40
View File
@@ -26,8 +26,6 @@ import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { useAuthor } from '@/hooks/useAuthor';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import { EXTRA_KINDS } from '@/lib/extraKinds';
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
@@ -46,40 +44,11 @@ type KindOption = {
// ─── Kind options (built once) ───────────────────────────────────────────────
export function buildKindOptions(): KindOption[] {
const options: KindOption[] = [];
for (const def of EXTRA_KINDS) {
if (def.subKinds) {
for (const sub of def.subKinds) {
options.push({
value: String(sub.kind),
label: `${sub.label} (${sub.kind})`,
description: sub.description,
parentId: def.id,
icon: CONTENT_KIND_ICONS[def.id],
});
}
} else {
options.push({
value: String(def.kind),
label: `${def.label} (${def.kind})`,
description: def.description,
parentId: def.id,
icon: CONTENT_KIND_ICONS[def.id],
});
}
}
const seen = new Set<string>();
return options.filter((o) => {
if (seen.has(o.value)) return false;
seen.add(o.value);
return true;
});
}
import { buildKindOptions } from '@/lib/feedFilterUtils';
// ─── useScrollCarets ─────────────────────────────────────────────────────────
export function useScrollCarets() {
function useScrollCarets() {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
@@ -534,12 +503,7 @@ export function ListPackPicker({ lists, followPacks, value, onSelectPubkeys, cla
// ─── parseSelectedKinds ───────────────────────────────────────────────────────
/** Parse a TabFilter's kinds array into an array of string kind values. */
export function parseSelectedKinds(filter: TabFilter): string[] {
const kinds = filter.kinds;
if (!Array.isArray(kinds) || kinds.length === 0) return [];
return kinds.map(String);
}
// ─── AuthorChip ───────────────────────────────────────────────────────────────
@@ -630,7 +594,7 @@ export function SavedFeedFiltersEditor({
);
const search = typeof value.search === 'string' ? value.search : '';
const authorPubkeys = Array.isArray(value.authors) ? (value.authors as string[]) : [];
const authorPubkeys = useMemo(() => Array.isArray(value.authors) ? (value.authors as string[]) : [], [value.authors]);
// Local scope state so clicking "People" immediately shows the panel,
// even before any authors have been added. Initialised from the filter value.
const [authorScope, setAuthorScopeState] = useState<'anyone' | 'people'>(
+71 -16
View File
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from 'react';
import { createContext, useContext } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { useNavHidden } from '@/contexts/LayoutContext';
import { SubHeaderBarContext } from '@/components/SubHeaderBarContext';
interface HoverSlice {
left: number;
@@ -21,17 +22,6 @@ interface SubHeaderBarProps {
pinned?: boolean;
}
interface SubHeaderBarContextValue {
onHover: (slice: HoverSlice | null) => void;
onActive: (slice: HoverSlice | null) => void;
}
export const SubHeaderBarContext = createContext<SubHeaderBarContextValue>({ onHover: () => {}, onActive: () => {} });
export function useSubHeaderBarHover() {
return useContext(SubHeaderBarContext);
}
/**
* Shared sticky sub-header bar with a unified arc+background drawn as a single
* SVG shape. Eliminates the sub-pixel seam between a bg-background/80 container
@@ -52,6 +42,44 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
const barRef = useRef<HTMLDivElement>(null);
const [atTop, setAtTop] = useState(false);
// Horizontal overflow scroll arrows (desktop only)
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkOverflow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const tolerance = 2; // sub-pixel rounding tolerance
setCanScrollLeft(el.scrollLeft > tolerance);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - tolerance);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
checkOverflow();
el.addEventListener('scroll', checkOverflow, { passive: true });
const ro = new ResizeObserver(checkOverflow);
ro.observe(el);
return () => {
el.removeEventListener('scroll', checkOverflow);
ro.disconnect();
};
}, [checkOverflow]);
// Also re-check overflow when children change (new tabs added/removed)
useEffect(() => {
checkOverflow();
}, [children, checkOverflow]);
const scrollBy = (direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const amount = el.clientWidth * 0.6;
el.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' });
};
useEffect(() => {
if (!pinned) return;
@@ -76,7 +104,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
const showSafeAreaPadding = pinned && navHidden && atTop;
return (
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive }}>
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive, scrollContainerRef: scrollRef }}>
<div
ref={barRef}
className={cn(
@@ -132,8 +160,35 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
</svg>
)}
{/* Tab content sits above the SVG background */}
<div className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}>
{children}
<div className="relative">
{/* Left scroll arrow — desktop only, shown when overflowing */}
{canScrollLeft && (
<button
type="button"
aria-label="Scroll tabs left"
onClick={() => scrollBy('left')}
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background/90 to-transparent cursor-pointer"
>
<ChevronLeft className="size-4 text-muted-foreground" />
</button>
)}
<div
ref={scrollRef}
className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}
>
{children}
</div>
{/* Right scroll arrow — desktop only, shown when overflowing */}
{canScrollRight && (
<button
type="button"
aria-label="Scroll tabs right"
onClick={() => scrollBy('right')}
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background/90 to-transparent cursor-pointer"
>
<ChevronRight className="size-4 text-muted-foreground" />
</button>
)}
</div>
</div>
</div>
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useCallback, useLayoutEffect, useEffect } from 'react';
interface HoverSlice {
left: number;
width: number;
}
interface SubHeaderBarContextValue {
onHover: (slice: HoverSlice | null) => void;
onActive: (slice: HoverSlice | null) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
}
export const SubHeaderBarContext = createContext<SubHeaderBarContextValue>({ onHover: () => {}, onActive: () => {}, scrollContainerRef: { current: null } });
export function useSubHeaderBarHover() {
return useContext(SubHeaderBarContext);
}
/**
* Shared hook for reporting the active tab's position to SubHeaderBar's arc indicator.
* Handles scroll-aware position reporting and cleans up on unmount/deactivation.
*
* @param active Whether this tab is currently active.
* @param elRef Ref to the tab's DOM element (used for offsetLeft/offsetWidth).
*/
export function useActiveTabIndicator(active: boolean, elRef: React.RefObject<HTMLElement | null>) {
const { onActive, scrollContainerRef } = useSubHeaderBarHover();
const reportSlice = useCallback(() => {
const el = elRef.current;
if (!el) return null;
const scrollOffset = scrollContainerRef.current?.scrollLeft ?? 0;
return { left: el.offsetLeft - scrollOffset, width: el.offsetWidth };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Report active slice to SubHeaderBar so the arc indicator renders.
useLayoutEffect(() => {
if (!active) return;
const s = reportSlice();
if (s) onActive(s);
return () => onActive(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
// Re-report position when the scroll container scrolls,
// so the SVG clip-path stays aligned with the visually shifted tab.
useEffect(() => {
if (!active) return;
const container = scrollContainerRef.current;
if (!container) return;
const handleScroll = () => {
const s = reportSlice();
if (s) onActive(s);
};
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
return { reportSlice };
}
+17 -10
View File
@@ -1,6 +1,6 @@
import { useRef, useLayoutEffect } from 'react';
import { cn } from '@/lib/utils';
import { useSubHeaderBarHover } from '@/components/SubHeaderBar';
import { useSubHeaderBarHover, useActiveTabIndicator } from '@/components/SubHeaderBarContext';
interface TabButtonProps {
/** Tab display label. */
@@ -26,18 +26,25 @@ interface TabButtonProps {
*/
export function TabButton({ label, active, onClick, disabled, className, children }: TabButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const { onHover, onActive } = useSubHeaderBarHover();
const reportSlice = () => {
const btn = ref.current;
if (!btn) return;
return { left: btn.offsetLeft, width: btn.offsetWidth };
};
const { onHover, scrollContainerRef } = useSubHeaderBarHover();
const { reportSlice } = useActiveTabIndicator(active, ref);
// Auto-scroll the active tab into view when the container overflows
useLayoutEffect(() => {
if (!active) return;
const s = reportSlice();
if (s) onActive(s);
const btn = ref.current;
const container = scrollContainerRef.current;
if (btn && container) {
const btnLeft = btn.offsetLeft;
const btnRight = btnLeft + btn.offsetWidth;
const viewLeft = container.scrollLeft;
const viewRight = viewLeft + container.clientWidth;
if (btnLeft < viewLeft) {
container.scrollTo({ left: btnLeft - 8, behavior: 'smooth' });
} else if (btnRight > viewRight) {
container.scrollTo({ left: btnRight - container.clientWidth + 8, behavior: 'smooth' });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
+1 -1
View File
@@ -20,7 +20,7 @@ import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
/** Extracts HSL color string from a theme token value like "258 70% 55%" */
function hsl(value: string): string {
+7 -3
View File
@@ -1,6 +1,7 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useState } from 'react';
import { NoteCard } from '@/components/NoteCard';
import { cn } from '@/lib/utils';
/** Maximum nesting depth before collapsing the rest of the thread. */
const MAX_RENDER_DEPTH = 3;
@@ -34,7 +35,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
return (
<div>
<NoteCard event={node.event} threaded />
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} />
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} isLast />
</div>
);
}
@@ -72,11 +73,14 @@ function countDescendants(node: ReplyNode): number {
return count;
}
function ExpandThreadButton({ count, onClick }: { count: number; onClick: () => void }) {
function ExpandThreadButton({ count, onClick, isLast }: { count: number; onClick: () => void; isLast?: boolean }) {
return (
<button
onClick={onClick}
className="flex items-center gap-3 px-4 pt-0 pb-2.5 w-full hover:bg-secondary/30 transition-colors group"
className={cn(
"flex items-center gap-3 px-4 pt-0 pb-2.5 w-full hover:bg-secondary/30 transition-colors group",
isLast && "border-b border-border",
)}
>
<div className="flex flex-col items-center w-10">
<div className="w-0.5 flex-1 mb-1 bg-foreground/20" />
+1 -139
View File
@@ -5,6 +5,7 @@ import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
import { usePlayerControls } from '@/hooks/usePlayerControls';
import { useVideoThumbnail } from '@/hooks/useVideoThumbnail';
import { formatTime } from '@/lib/formatTime';
interface VideoPlayerProps {
@@ -30,145 +31,6 @@ function parseDim(dim: string | undefined): { width: number; height: number } |
}
/**
* Extracts a thumbnail frame from a video URL by loading it off-screen,
* drawing the first frame to a canvas, and returning a data URL.
* Works reliably on Android WebView where preload="metadata" doesn't render a visible frame.
*/
export function useVideoThumbnail(src: string, poster: string | undefined): string | undefined {
const [thumbnail, setThumbnail] = useState<string | undefined>(poster);
useEffect(() => {
// Skip if we already have a poster image
if (poster) return;
if (!src) return;
let cancelled = false;
function grabFrameFromUrl(videoSrc: string) {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.playsInline = true;
video.preload = 'metadata';
video.src = videoSrc;
function captureFrame() {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
if (dataUrl.length > 1000) setThumbnail(dataUrl);
}
} catch { /* CORS or tainted canvas */ }
video.src = '';
video.load();
}
// After metadata loads, seek to 0.1s — then capture on seeked
const handleMetadata = () => { video.currentTime = 0.1; };
const handleSeeked = () => captureFrame();
video.addEventListener('loadedmetadata', handleMetadata, { once: true });
video.addEventListener('seeked', handleSeeked, { once: true });
return () => {
video.removeEventListener('loadedmetadata', handleMetadata);
video.removeEventListener('seeked', handleSeeked);
video.src = '';
video.load();
};
}
// For HLS: use hls.js to load the stream into an off-screen video, then grab a frame
if (/\.m3u8(\?|$)/i.test(src)) {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.playsInline = true;
// Safari — native HLS support, no need for hls.js
if (video.canPlayType('application/vnd.apple.mpegurl')) {
const grabFrame = () => {
if (cancelled) return;
video.play().then(() => {
video.pause();
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
if (dataUrl.length > 1000) setThumbnail(dataUrl);
}
} catch { /* tainted canvas */ }
video.src = '';
}).catch(() => { /* ignore */ });
};
video.src = src;
video.addEventListener('loadeddata', grabFrame, { once: true });
return () => {
cancelled = true;
video.removeEventListener('loadeddata', grabFrame);
video.src = '';
};
}
// Non-Safari: dynamically import hls.js
let hlsInstance: Hls | null = null;
import('hls.js').then(({ default: HlsLib }) => {
if (cancelled || !HlsLib.isSupported()) return;
const grabFrame = () => {
if (cancelled) return;
video.play().then(() => {
video.pause();
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
if (dataUrl.length > 1000) setThumbnail(dataUrl);
}
} catch { /* tainted canvas */ }
hlsInstance?.destroy();
hlsInstance = null;
video.src = '';
}).catch(() => { hlsInstance?.destroy(); hlsInstance = null; });
};
const hls = new HlsLib({ startLevel: -1, maxBufferLength: 5 });
hlsInstance = hls;
hls.loadSource(src);
hls.attachMedia(video);
hls.on(HlsLib.Events.MANIFEST_PARSED, () => {
if (cancelled) { hls.destroy(); return; }
grabFrame();
});
});
return () => { cancelled = true; hlsInstance?.destroy(); hlsInstance = null; video.src = ''; };
}
// Regular video file
const cleanupDirect = grabFrameFromUrl(src);
return () => { cancelled = true; cleanupDirect(); };
}, [src, poster]);
return thumbnail;
}
/** Attaches hls.js to a video element for HLS streams on non-Safari browsers. */
function useHls(videoRef: React.RefObject<HTMLVideoElement | null>, src: string) {
const hlsRef = useRef<Hls | null>(null);
+1
View File
@@ -113,6 +113,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
await publishEvent({
kind: 0,
content: JSON.stringify(profileData),
tags: [],
});
}
} catch {
+2 -1
View File
@@ -33,7 +33,8 @@ import { DrawingCanvas } from './DrawingCanvas';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { StationeryBackground } from './StationeryBackground';
import { SendAnimation, useEnvelopeDimensions } from './SendAnimation';
import { SendAnimation } from './SendAnimation';
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
/** Lightweight letter preview used inside the send animation */
function AnimationLetter({ content, width }: { content: LetterContent; width: number }) {
+1 -1
View File
@@ -17,7 +17,7 @@ import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
import { useTheme } from '@/hooks/useTheme';
import { toast } from '@/hooks/useToast';
import { paletteToTheme, getColors } from '@/components/ColorMomentContent';
import { paletteToTheme, getColors } from '@/lib/colorMomentUtils';
import { parseThemeDefinition } from '@/lib/themeEvent';
import type { ThemeConfig } from '@/themes';
import {
+1 -1
View File
@@ -143,7 +143,7 @@ export function LetterEditor({
});
ro.observe(el);
return () => ro.disconnect();
}, []);
}, [cardRef]);
// Load the currently-selected font on mount/change so the card preview renders correctly.
useEffect(() => {
+1 -27
View File
@@ -10,6 +10,7 @@
import { useId, useRef, useEffect, useLayoutEffect, useCallback, useState, useMemo } from 'react';
import { hexToRgb, rgbToHex, darkenHex, blendHex } from '@/lib/colorUtils';
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
// ---------------------------------------------------------------------------
// Easing + animation driver
@@ -40,34 +41,7 @@ function animateVal(
// Envelope dimensions — responsive, using vw-based sizing
// ---------------------------------------------------------------------------
export function useEnvelopeDimensions() {
const [dims, setDims] = useState(() => calcDims(window.innerWidth));
useEffect(() => {
const onResize = () => setDims(calcDims(window.innerWidth));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return dims;
}
function calcDims(vw: number) {
const envW = Math.min(Math.round(vw * 0.85), 420);
const envH = Math.round(envW / 1.588);
const r = Math.round(envW * 0.041);
const s = envW / 54;
const flapY = Math.round(envH * 0.147);
const vY = Math.round(envH * 0.647);
const flapTriH = Math.round(vY * 1.08);
const letterW = Math.round(envW * 0.82);
const letterH = letterW / (5 / 4);
const strokeV = Math.round(s * 1.6 * 10) / 10;
const strokeCorner = Math.round(s * 1.4 * 10) / 10;
return { envW, envH, r, flapY, vY, flapTriH, letterW, letterH, strokeV, strokeCorner };
}
// ---------------------------------------------------------------------------
// Derive envelope palette from stationery background + primary colors
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/contexts/PortalContainerContext"
import { usePortalContainer } from "@/hooks/usePortalContainer"
const Popover = PopoverPrimitive.Root
+3 -71
View File
@@ -1,71 +1,7 @@
import { createContext, useContext, useRef, useState, useCallback, useEffect } from 'react';
import { useRef, useState, useCallback, useEffect } from 'react';
import type { ReactNode } from 'react';
/** A track that can be played by the global audio player. */
export interface AudioTrack {
/** Nostr event ID. */
id: string;
/** Track title. */
title: string;
/** Artist or author name. */
artist: string;
/** Audio file URL. */
url: string;
/** Artwork/cover image URL. */
artwork?: string;
/** Duration in seconds (from metadata). */
duration?: number;
/** Navigation path to the track's detail page (e.g. /naddr1…). */
path?: string;
}
interface AudioPlayerState {
/** Currently loaded track. */
currentTrack: AudioTrack | null;
/** Playlist tracks (when playing a playlist). */
playlist: AudioTrack[];
/** Current index within the playlist. */
currentIndex: number;
/** Whether the player is minimized (floating bar). */
minimized: boolean;
/** Whether audio is currently playing. */
isPlaying: boolean;
/** Current playback time in seconds. */
currentTime: number;
/** Total duration in seconds. */
duration: number;
/** Volume (01). */
volume: number;
}
interface AudioPlayerActions {
/** Play a single track. */
playTrack: (track: AudioTrack) => void;
/** Play a playlist starting at a given index. */
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
/** Pause playback. */
pause: () => void;
/** Resume playback. */
resume: () => void;
/** Seek to a position in seconds. */
seek: (time: number) => void;
/** Set volume (01). */
setVolume: (v: number) => void;
/** Skip to next track (playlist mode). */
nextTrack: () => void;
/** Skip to previous track (playlist mode). */
prevTrack: () => void;
/** Minimize the player (show floating bar). */
minimize: () => void;
/** Expand the player (navigate back to source). */
expand: () => void;
/** Stop playback and close the player. */
stop: () => void;
}
type AudioPlayerContextType = AudioPlayerState & AudioPlayerActions;
const AudioPlayerContext = createContext<AudioPlayerContextType | undefined>(undefined);
import { AudioPlayerContext, type AudioTrack } from '@/contexts/audioPlayerContextDef';
const VOLUME_KEY = 'audio-player-volume';
@@ -330,8 +266,4 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
);
}
export function useAudioPlayer(): AudioPlayerContextType {
const ctx = useContext(AudioPlayerContext);
if (!ctx) throw new Error('useAudioPlayer must be used within AudioPlayerProvider');
return ctx;
}
+73
View File
@@ -0,0 +1,73 @@
import { createContext, useContext } from 'react';
/** A track that can be played by the global audio player. */
export interface AudioTrack {
/** Nostr event ID. */
id: string;
/** Track title. */
title: string;
/** Artist or author name. */
artist: string;
/** Audio file URL. */
url: string;
/** Artwork/cover image URL. */
artwork?: string;
/** Duration in seconds (from metadata). */
duration?: number;
/** Navigation path to the track's detail page (e.g. /naddr1…). */
path?: string;
}
export interface AudioPlayerState {
/** Currently loaded track. */
currentTrack: AudioTrack | null;
/** Playlist tracks (when playing a playlist). */
playlist: AudioTrack[];
/** Current index within the playlist. */
currentIndex: number;
/** Whether the player is minimized (floating bar). */
minimized: boolean;
/** Whether audio is currently playing. */
isPlaying: boolean;
/** Current playback time in seconds. */
currentTime: number;
/** Total duration in seconds. */
duration: number;
/** Volume (01). */
volume: number;
}
export interface AudioPlayerActions {
/** Play a single track. */
playTrack: (track: AudioTrack) => void;
/** Play a playlist starting at a given index. */
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
/** Pause playback. */
pause: () => void;
/** Resume playback. */
resume: () => void;
/** Seek to a position in seconds. */
seek: (time: number) => void;
/** Set volume (01). */
setVolume: (v: number) => void;
/** Skip to next track (playlist mode). */
nextTrack: () => void;
/** Skip to previous track (playlist mode). */
prevTrack: () => void;
/** Minimize the player (show floating bar). */
minimize: () => void;
/** Expand the player (navigate back to source). */
expand: () => void;
/** Stop playback and close the player. */
stop: () => void;
}
export type AudioPlayerContextType = AudioPlayerState & AudioPlayerActions;
export const AudioPlayerContext = createContext<AudioPlayerContextType | undefined>(undefined);
export function useAudioPlayer(): AudioPlayerContextType {
const ctx = useContext(AudioPlayerContext);
if (!ctx) throw new Error('useAudioPlayer must be used within AudioPlayerProvider');
return ctx;
}
+4 -4
View File
@@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -24,9 +23,9 @@ export function useAcceptBadge() {
if (!user) throw new Error('User is not logged in');
// Fetch the freshest profile badges event from relays (both kinds)
const freshEvent = await fetchFreshProfileBadges(nostr, user.pubkey);
const prev = await fetchFreshProfileBadges(nostr, user.pubkey);
const currentTags = freshEvent?.tags ?? [['d', 'profile_badges']];
const currentTags = prev?.tags ?? [['d', 'profile_badges']];
// Don't add duplicates
const alreadyHas = currentTags.some(
@@ -48,7 +47,8 @@ export function useAcceptBadge() {
kind: BADGE_PROFILE_KIND,
content: '',
tags: newTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
prev: prev ?? undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile-badges', user?.pubkey] });
+1 -1
View File
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
/** Minimal reference to a badge definition for querying. */
+5 -5
View File
@@ -3,7 +3,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { NostrEvent } from '@nostrify/nostrify';
/** Hook to manage the user's NIP-51 bookmark list (kind 10003). */
export function useBookmarks() {
@@ -63,12 +62,12 @@ export function useBookmarks() {
if (!user) throw new Error('User is not logged in');
// Fetch the freshest kind 10003 from relays before mutating
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
const currentlyBookmarked = currentTags.some(
([name, id]) => name === 'e' && id === eventId,
);
@@ -87,10 +86,11 @@ export function useBookmarks() {
await publishEvent({
kind: 10003,
content: freshEvent?.content ?? '',
content: prev?.content ?? '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
prev: prev ?? undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookmarks', user?.pubkey] });
+30
View File
@@ -0,0 +1,30 @@
import { useState, useEffect } from 'react';
function calcDims(vw: number) {
const envW = Math.min(Math.round(vw * 0.85), 420);
const envH = Math.round(envW / 1.588);
const r = Math.round(envW * 0.041);
const s = envW / 54;
const flapY = Math.round(envH * 0.147);
const vY = Math.round(envH * 0.647);
const flapTriH = Math.round(vY * 1.08);
const letterW = Math.round(envW * 0.82);
const letterH = letterW / (5 / 4);
const strokeV = Math.round(s * 1.6 * 10) / 10;
const strokeCorner = Math.round(s * 1.4 * 10) / 10;
return { envW, envH, r, flapY, vY, flapTriH, letterW, letterH, strokeV, strokeCorner };
}
export function useEnvelopeDimensions() {
const [dims, setDims] = useState(() => calcDims(window.innerWidth));
useEffect(() => {
const onResize = () => setDims(calcDims(window.innerWidth));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return dims;
}
+4 -3
View File
@@ -113,10 +113,10 @@ export function useFollowActions(): UseFollowActionsReturn {
try {
// ① Fetch the freshest kind 3 event via pool
const latestEvent = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ② Separate tags into `p` tags (follow entries) and everything else
const existingTags = latestEvent?.tags ?? [];
const existingTags = prev?.tags ?? [];
const pTags = existingTags.filter(([name]) => name === 'p');
const nonPTags = existingTags.filter(([name]) => name !== 'p');
@@ -135,12 +135,13 @@ export function useFollowActions(): UseFollowActionsReturn {
const newTags = [...nonPTags, ...newPTags];
// ⑤ Preserve the content field (relay hints / petnames in some clients)
const content = latestEvent?.content ?? '';
const content = prev?.content ?? '';
await publishEvent({
kind: 3,
content,
tags: newTags,
prev: prev ?? undefined,
});
// ⑥ Invalidate cached follow-list queries so UI updates
+1
View File
@@ -360,6 +360,7 @@ export function useInitialSync() {
nostr,
config.appId,
config.relayMetadata.updatedAt,
config.blossomServerMetadata.updatedAt,
updateConfig,
queryClient,
markSyncComplete,
+11 -10
View File
@@ -9,7 +9,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { NostrEvent } from '@nostrify/nostrify';
export function useInterests(tagName: 't' | 'g' = 't') {
const { nostr } = useNostr();
@@ -57,12 +56,12 @@ export function useInterests(tagName: 't' | 'g' = 't') {
if (!normalized) throw new Error('Empty tag');
// Fetch the freshest kind 10015 from relays before mutating
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10015],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
// Don't add duplicates
if (currentTags.some(([n, v]) => n === tagName && v.toLowerCase() === normalized)) return;
@@ -70,9 +69,10 @@ export function useInterests(tagName: 't' | 'g' = 't') {
const newTags = [...currentTags, [tagName, normalized]];
await publishEvent({
kind: 10015,
content: freshEvent?.content ?? '',
content: prev?.content ?? '',
tags: newTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
prev: prev ?? undefined,
});
},
onSuccess: invalidate,
});
@@ -84,21 +84,22 @@ export function useInterests(tagName: 't' | 'g' = 't') {
const normalized = tag.toLowerCase().replace(/^#/, '');
// Fetch the freshest kind 10015 from relays before mutating
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10015],
authors: [user.pubkey],
});
if (!freshEvent) return;
if (!prev) return;
const newTags = freshEvent.tags.filter(
const newTags = prev.tags.filter(
([name, value]) => !(name === tagName && value.toLowerCase() === normalized),
);
await publishEvent({
kind: 10015,
content: freshEvent.content ?? '',
content: prev.content ?? '',
tags: newTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
prev,
});
},
onSuccess: invalidate,
});
+9 -8
View File
@@ -204,8 +204,8 @@ export function useMuteList() {
}
// ① Fetch the freshest kind 10000 from relays before mutating
const freshEvent = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
const currentItems = await getAllMuteItems(freshEvent, user.signer, user.pubkey);
const prev = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
const currentItems = await getAllMuteItems(prev, user.signer, user.pubkey);
// ② Add only if not already present (dedup)
const alreadyMuted = currentItems.some(
@@ -218,7 +218,7 @@ export function useMuteList() {
// Update localStorage immediately so it survives page refresh
setCachedMuteItems(user.pubkey, newItems);
await updateMuteList(newItems);
await updateMuteList(newItems, prev);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['muteList', user?.pubkey] });
@@ -231,8 +231,8 @@ export function useMuteList() {
if (!user) throw new Error('User not logged in');
// ① Fetch the freshest kind 10000 from relays before mutating
const freshEvent = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
const currentItems = await getAllMuteItems(freshEvent, user.signer, user.pubkey);
const prev = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
const currentItems = await getAllMuteItems(prev, user.signer, user.pubkey);
// ② Remove the target item
const newItems = currentItems.filter(
@@ -242,7 +242,7 @@ export function useMuteList() {
// Update localStorage immediately so it survives page refresh
setCachedMuteItems(user.pubkey, newItems);
await updateMuteList(newItems);
await updateMuteList(newItems, prev);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['muteList', user?.pubkey] });
@@ -250,7 +250,7 @@ export function useMuteList() {
});
// Update entire mute list
const updateMuteList = async (items: MuteListItem[]) => {
const updateMuteList = async (items: MuteListItem[], prev: NostrEvent | null) => {
if (!user) throw new Error('User not logged in');
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported');
@@ -274,7 +274,8 @@ export function useMuteList() {
await publishEvent({
kind: 10000,
content,
tags: [], // No public tags, everything encrypted
tags: [],
prev: prev ?? undefined,
});
};
+42 -5
View File
@@ -7,6 +7,25 @@ import { useCurrentUser } from "./useCurrentUser";
import type { NostrEvent } from "@nostrify/nostrify";
/** Event template accepted by `useNostrPublish`. */
export type EventTemplate = Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & {
/**
* The previous version of the event being replaced (for replaceable/addressable kinds).
* When provided, `published_at` from the old event is preserved on the new one.
* When omitted and the kind is replaceable or addressable, `published_at` is set
* equal to `created_at` so the two always match on first publish.
*/
prev?: NostrEvent;
};
/** Returns true if the kind falls in a replaceable or addressable range. */
function isReplaceableKind(kind: number): boolean {
// Legacy replaceable kinds
if (kind === 0 || kind === 3) return true;
// Replaceable (1000019999) or addressable (3000039999)
return (kind >= 10000 && kind < 20000) || (kind >= 30000 && kind < 40000);
}
/**
* Builds a NIP-89 "client" tag from the app display name and an optional
* `naddr1` identifier for the kind 31990 handler event.
@@ -41,9 +60,11 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
const { config } = useAppContext();
return useMutation({
mutationFn: async (t: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>) => {
mutationFn: async (t: EventTemplate) => {
if (user) {
const tags = t.tags ?? [];
// Extract `prev` before building the event — it's not part of the Nostr event schema.
const { prev, ...template } = t;
const tags = [...(template.tags ?? [])];
// Add the NIP-89 client tag if it doesn't exist
if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) {
@@ -51,11 +72,27 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
tags.push(clientTag);
}
const created_at = template.created_at ?? Math.floor(Date.now() / 1000);
// Handle published_at for replaceable/addressable events (NIP-24)
if (isReplaceableKind(template.kind) && !tags.some(([name]) => name === "published_at")) {
if (prev) {
// Preserve published_at from the previous event if it had one
const oldTag = prev.tags.find(([name]) => name === "published_at");
if (oldTag) {
tags.push(["published_at", oldTag[1]]);
}
} else {
// First publish: set published_at equal to created_at
tags.push(["published_at", String(created_at)]);
}
}
const event = await user.signer.signEvent({
kind: t.kind,
content: t.content ?? "",
kind: template.kind,
content: template.content ?? "",
tags,
created_at: t.created_at ?? Math.floor(Date.now() / 1000),
created_at,
});
if (event.pubkey !== user.pubkey) {
+5 -5
View File
@@ -3,7 +3,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to manage NIP-51 pinned notes (kind 10001).
@@ -50,12 +49,12 @@ export function usePinnedNotes(pubkey?: string) {
if (!user) throw new Error('User is not logged in');
// Fetch the freshest kind 10001 from relays before mutating
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10001],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
const currentlyPinned = currentTags.some(
([name, id]) => name === 'e' && id === eventId,
);
@@ -74,10 +73,11 @@ export function usePinnedNotes(pubkey?: string) {
await publishEvent({
kind: 10001,
content: freshEvent?.content ?? '',
content: prev?.content ?? '',
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
prev: prev ?? undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pinned-notes', user?.pubkey] });
+1 -1
View File
@@ -2,7 +2,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY } from '@/lib/badgeUtils';
/**

Some files were not shown because too many files have changed in this diff Show More