Compare commits

...

253 Commits

Author SHA1 Message Date
Alex Gleason 2bb1b07dd6 release: v2.2.11 2026-04-02 03:05:10 -05:00
Alex Gleason f93c759bf2 Fix VersionCheck crash: move VersionCheck and Toaster inside BrowserRouter
VersionCheck and Toaster were rendering outside the BrowserRouter in App.tsx,
so the <Link> in the version update toast had no Router context. Moved both
into AppRouter.tsx inside BrowserRouter. Also truncate changelog excerpt
to 60 chars with ellipsis for cleaner toast display.
2026-04-02 03:01:32 -05:00
Alex Gleason ef4ac2e3f4 release: v2.2.10 2026-04-02 02:48:34 -05:00
Alex Gleason 32b36b2f54 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 02:40:44 -05:00
Alex Gleason dee5c82fa8 Add 'deployed an nsite' action header to nsite detail page 2026-04-02 02:39:40 -05:00
Alex Gleason 22d66a28d7 Add Open App button to compact view, fix stopPropagation on all buttons 2026-04-02 02:37:13 -05:00
Alex Gleason 984a56c412 Add 'published an app' action header to app detail page 2026-04-02 02:34:14 -05:00
Alex Gleason 207e7a13a2 Move Shakespeare badge above Open App button, restore h-6 size 2026-04-02 02:25:44 -05:00
Alex Gleason cc7feebbb0 Replace small external link icon with prominent Visit Website button 2026-04-02 02:22:25 -05:00
Alex Gleason 925619b13c Add background color to app icon for transparent images 2026-04-02 02:09:58 -05:00
Alex Gleason ceb7bbc718 Fix app icon z-index so it renders above the og:image hero 2026-04-02 02:06:41 -05:00
Alex Gleason 53a607fa53 Overlay app icon over og:image like a profile avatar 2026-04-02 01:57:56 -05:00
Alex Gleason e9eeebc4b1 Rename 'App Handler' to 'App' in UI labels 2026-04-02 01:48:08 -05:00
Alex Gleason b42d241882 Fix Shakespeare clone URL to use NostrURI class 2026-04-02 01:43:33 -05:00
Alex Gleason 68da609a9e Hide app handler screenshot hero when no og:image, reduce image height 2026-04-02 01:38:07 -05:00
Chad Curtis 1afa78ae39 Merge branch 'fix/disappearing-post-box' into 'main'
Fix disappearing compose box after posting

See merge request soapbox-pub/ditto!141
2026-04-02 06:29:56 +00:00
Alex Gleason e0ff462f12 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 01:06:25 -05:00
Alex Gleason f4e38123e4 Add display for kind 31990 NIP-89 app handler events 2026-04-02 01:00:32 -05:00
Alex Gleason eb1c873b9a Show 'What's new' with changelog excerpt in version update toast 2026-04-01 23:50:22 -05:00
Alex Gleason 22f13c1505 Replace __DITTO_CONFIG__ global with import.meta.env.DITTO_CONFIG and remove ThemeSchemaCompat
Move build-time ditto.json injection from a Vite define global to
import.meta.env.DITTO_CONFIG (a JSON string parsed and validated at
runtime via DittoConfigSchema). Remove the global type declaration
from vite-env.d.ts.

Drop ThemeSchemaCompat and its legacy "black"/"pink" migration code
from AppProvider and NostrSync — invalid theme values now simply fail
Zod validation.

Fix a latent bug where a partial feedSettings from ditto.json would
replace the full hardcoded defaults; defaultConfig now deep-merges
feedSettings.
2026-04-01 23:16:33 -05:00
Alex Gleason cbfc8f149f Redesign changelog page: hero latest release, collapsible entries, flat item list with category icons, tooltips, typography fixes, and search integration 2026-04-01 22:33:18 -05:00
Alex Gleason 2e41859747 Show update toast with changelog link when app version changes 2026-04-01 21:26:23 -05:00
Alex Gleason 3b176a3e8f release: v2.2.9 2026-04-01 21:12:42 -05:00
Alex Gleason a1e1e1d57f Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:55:01 -05:00
Alex Gleason eb973cc20b Redesign shop/items dialog: tile layout, instant buy, remove accessories and categories 2026-04-01 20:53:56 -05:00
Alex Gleason f66ab92e51 Unify Shop/Inventory, remove Info modal and visibility, consolidate dev tools
- Combine Shop and Inventory into a single tabbed dialog (Shop tab
  with category sub-tabs, Inventory tab with item list and use flow)
- Remove BlobbiInfoModal entirely
- Move dev tools (Dev Hatch/Evolve, State Editor, Emotion Tester) into
  the bottom bar 'More' dropdown with yellow text, remove floating
  dev tools panel
- Remove 'visibleToOthers' / 'visible_to_others' concept from the
  entire codebase: types, interfaces, tag schemas, event construction,
  parsing, UI badges, dev editor, and documentation
2026-04-01 20:14:34 -05:00
Alex Gleason 4d573ffaa8 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-01 20:13:37 -05:00
Alex Gleason 081189886a Hide emoji packs without any valid emoji tags from feeds 2026-04-01 20:07:43 -05:00
Alex Gleason 1efc8de880 Add description field to emoji pack create/edit dialog
Parse and publish the 'about' tag for kind 30030 emoji sets.
EmojiPackContent already displays it.
2026-04-01 20:02:01 -05:00
Alex Gleason 8bf9db382e Fix emoji pack drag reorder, expand truncated grid, resolve shortcode collisions
- Replace chevron up/down buttons with @dnd-kit SortableList/SortableItem
  for proper drag-and-drop reorder in EmojiPackDialog
- Remove 'N emojis' badge from emoji pack display
- Make '+N' overflow indicator clickable to expand full emoji grid
- Stop click propagation on expand button to prevent feed navigation
- Resolve shortcode collisions across emoji packs by prefixing with pack
  d-tag identifier when two packs define the same shortcode with different URLs
2026-04-01 19:57:29 -05:00
Alex Gleason 103b9c71bf Move right-side floating controls into bottom bar 3-dots menu
Replace the cluster of floating action buttons on the right side of the
Blobbi dashboard with a single 'More' dropdown in the bottom control bar.
The menu contains: Inventory, Take a Photo, Set as Companion, Evolve/Hatch,
Blobbi Info, and View Blobbi. The floating controls now only show the back
button (left) and dev tools (right, localhost only).
2026-04-01 19:29:15 -05:00
Alex Gleason e27057788b Replace drag reorder with move buttons, remove colons and parenthetical count
- Swap native HTML drag-and-drop reorder for chevron up/down buttons,
  fixing scroll conflicts inside ScrollArea and drop zone interference
- Remove the colon decorations around shortcode inputs
- Show plain 'N emojis' count without parenthetical upload note
2026-04-01 19:20:30 -05:00
Alex Gleason 4983b3c1ef Use single upload icon in emoji drop zone 2026-04-01 19:14:35 -05:00
Alex Gleason 197ab6c28a Simplify BlobbiStateCard to show just the character and name 2026-04-01 19:14:01 -05:00
Alex Gleason fd0d47160d Defer all Blossom uploads and event signing until submit
Files are held as local blob previews while editing. Nothing is
uploaded or signed until the user clicks the publish button, at which
point all pending files are uploaded in parallel and then the event
is published in a single batch.
2026-04-01 19:10:09 -05:00
Alex Gleason 4697d269bc Put Title and ID side-by-side, auto-slug ID from Title, remove NIP jargon 2026-04-01 19:02:39 -05:00
Alex Gleason 73bf03cfab Add Blobbi view link and register kind 31124 in feed/detail views
Add a 3-dots menu to the Blobbi dashboard with a 'View Blobbi' link that
navigates to the naddr detail page. Register kind 31124 (Blobbi Pet State)
across all UI registration points so Blobbi events render properly in
feeds, detail pages, comment contexts, and embedded previews.
2026-04-01 19:01:47 -05:00
Alex Gleason c3d4d5f06e Add emoji pack create/edit dialog with drag-and-drop upload
Introduce EmojiPackDialog for publishing and editing kind 30030 custom
emoji sets (NIP-30). The dialog supports multi-file and folder
drag-and-drop, automatically extracting shortcodes from filenames. The
d-tag identifier is locked after initial publish. Existing packs show
an Edit button on the feed card for the author. The /emojis page FAB
now opens the create dialog.
2026-04-01 18:53:51 -05:00
Alex Gleason 4c201cc2d3 release: v2.2.8 2026-04-01 18:39:05 -05:00
Alex Gleason c30a6a7bcd Merge branch 'remove-egg-task' into 'main'
Remove egg maintain_stats task and hide companion button for eggs

See merge request soapbox-pub/ditto!151
2026-04-01 23:15:11 +00:00
Alex Gleason c4354774ad Add Color Moments and Geocaching to community NIP specs, sort table by kind number 2026-04-01 18:05:52 -05:00
Alex Gleason 8a44f77fb1 Add community NIP specs to NIP.md (Letters, Weather, Blobbi)
Add a Community Kinds section to the overview table and a Community
NIP Specifications section with summaries and links to the full specs
maintained by Chad Curtis, Sam Thomson, and Danifra.
2026-04-01 17:56:50 -05:00
Chad Curtis 9ebd9a304f Fix notification dot not clearing after marking as read
setQueryData requires an exact query key match, but the unread
notifications query uses a 4-element key (prefix, pubkey, kindsKey,
authorsKey). The markAsRead callback was calling setQueryData with only
2 elements, silently missing the cache entry. Switch to setQueriesData
which uses prefix matching, correctly hitting the real cache entry.
2026-04-01 16:24:23 -05:00
filemon b223a9c1f2 Remove egg maintain_stats task and hide companion button for eggs
Egg stats no longer decay, so the 'Keep Egg Healthy' dynamic task is
unnecessary and misleading. Remove it along with HATCH_STAT_THRESHOLD.
The baby/adult 'Peak Condition' evolve task is unchanged.

Also hide the 'Set as Companion' button entirely for eggs instead of
rendering it as disabled.
2026-04-01 15:03:29 -03:00
Alex Gleason 6be49ec14a Merge branch 'hide-all-status' into 'main'
Centralize stat visibility threshold (<70) across all Blobbi stages

See merge request soapbox-pub/ditto!149
2026-04-01 17:58:18 +00:00
Alex Gleason 793b408e3f Fix encrypted letter envelope to show back (mailing) side first
The envelope previously showed flap-like V-fold lines on both sides,
which doesn't match how real envelopes work. Now the default view
shows the back/mailing side with sender name top-left and recipient
name centered, then flipping reveals the front with the triangular
flap and wax seal, and clicking opens the flap to reveal the Nushu
ciphertext.
2026-04-01 12:56:23 -05:00
filemon 213e8abf28 Merge branch 'main' into hide-all-status 2026-04-01 14:52:39 -03:00
filemon bac2f3a5c7 Centralize stat visibility threshold (<70) across all Blobbi stages
Move the hardcoded < 70 stat visibility checks from BlobbiPage.tsx into
the shared getVisibleStatsWithValues() utility in blobbi-decay.ts. This
ensures egg, baby, and adult stages all use the same STAT_VISIBILITY_THRESHOLD
constant, and any future UI consuming visibleStats gets the filtering for free.
2026-04-01 14:47:07 -03:00
Alex Gleason 1e38d9d2a2 Add clientName to Zod schema and document AppConfig update process 2026-04-01 12:25:45 -05:00
Alex Gleason 419c1ceb48 Add clientName config option for the NIP-89 client tag 2026-04-01 12:06:25 -05:00
Alex Gleason f6bde5871a Remove dead EGG_DECAY constants since egg stage no longer decays 2026-04-01 11:54:29 -05:00
Alex Gleason 9c56a7f987 Revert "Fix unused EGG_DECAY variable eslint errors by prefixing with underscore"
This reverts commit 2e8efab2aa.
2026-04-01 11:53:03 -05:00
Alex Gleason 2e8efab2aa Fix unused EGG_DECAY variable eslint errors by prefixing with underscore 2026-04-01 11:52:08 -05:00
Alex Gleason 0f45ce743f Consolidate 3 dev buttons into a single dropdown menu 2026-04-01 11:36:30 -05:00
Alex Gleason 7794cd5dbd Disable stat decay for egg stage and reset all stats to 100 on hatch 2026-04-01 11:31:58 -05:00
Alex Gleason c0cb6454ac Only show Health, Hygiene, and Happy gauges when value is below 70% 2026-04-01 11:27:33 -05:00
Alex Gleason 2f45a9bbf5 Replace Users icon with Egg icon for Blobbies button and dialog 2026-04-01 11:22:54 -05:00
Alex Gleason 11a61322e8 Remove unused isFetching prop from BlobbiDashboard 2026-04-01 11:17:44 -05:00
Alex Gleason cb1bc1a865 Add tap-to-wiggle interaction on Blobbi eggs
Clicking any egg triggers a playful rock-and-hop animation (0.6s)
that wobbles side to side with a small upward jump, then settles.
Uses CSS animation with onAnimationEnd to auto-reset state.
Respects prefers-reduced-motion and doesn't interrupt cracking.
2026-04-01 11:14:30 -05:00
Alex Gleason 622cb14813 Make bottom bar flow inline below stats, remove stage badge, update loading skeleton 2026-04-01 11:03:35 -05:00
Alex Gleason 4afea98e77 Align bottom bar max-width with content container after card removal 2026-04-01 10:58:46 -05:00
Alex Gleason 79f3cc85dd Merge branch 'implement-reactions-clean' into 'main'
Implement Blobbi XP Progression, Interaction Features, and Refactors

See merge request soapbox-pub/ditto!147
2026-04-01 15:46:44 +00:00
Alex Gleason 4052f865c9 Address MR review: sanitize instanceId in eye-animation, add blobbi-xp tests
- Sanitize instanceId in eye-animation.ts with the same regex pattern
  used in svg/ids.ts for defense-in-depth consistency
- Add comprehensive unit tests for blobbi-xp.ts covering all pure
  functions: calculateActionXP, calculateInventoryActionXP, applyXPGain,
  getXPGainSummary, formatXPGain, getXPGainMessage, and XP constants
2026-04-01 10:44:23 -05:00
Chad Curtis 5887f790c6 Style expanded thread siblings with distinct connector line
Add threadedLineClassName prop to NoteCard to allow customizing the
connector line color. Revealed hidden siblings use bg-primary/30
to visually distinguish them from the main thread chain. Remove
bottom border from the expand thread button for seamless flow.
2026-04-01 03:46:04 -05:00
Chad Curtis 6fc5d3ed97 Add 'Show X more replies' for branching threads
When a reply has multiple children, only the first child renders
inline in the thread chain. Remaining siblings are hidden behind
a 'Show N more replies' button placed between the parent and
its inline child. Clicking reveals them as threaded items with
the connector line. Removes bottom border from the expand button
so it flows seamlessly in the thread.
2026-04-01 03:42:13 -05:00
Chad Curtis 0eaf30cd8b Fix threaded replies: only show first child inline, hide siblings
The linear threading UI (connector lines) only works for single chains.
When a reply had multiple children, siblings after the first rendered
without any visual connection to their parent, making them look like
top-level replies. Fix by only including the first child in each node's
thread chain — additional siblings are hidden since there is no UI to
display branching threads.
2026-04-01 03:36:26 -05:00
Chad Curtis f1d5e8d4ca Move broadcast button from overflow menu to Event JSON dialog 2026-04-01 02:47:24 -05:00
Chad Curtis 7763aa2e0a Add broadcast event option to overflow menu 2026-04-01 02:45:00 -05:00
Chad Curtis 500f06b538 DRY up drag-and-drop: refactor profile field reorder to use shared SortableList/SortableItem
- Refactor ProfileSettings SortableFieldRow to use SortableItem instead
  of manual useSortable/GripVertical/CSS.Transform boilerplate
- Replace inline DndContext/SortableContext with SortableList wrapper
- Add gripClassName prop to SortableItem for width customization
  (w-6 h-9 for profile fields, default w-8 for badges/sidebar)
- Add space-y-3 to SortableList in profile fields for row padding
- Remove all direct @dnd-kit imports from ProfileSettings
- Remove unused onOpenCreate prop chain from MyBadgesTab
2026-04-01 02:37:27 -05:00
Chad Curtis 85227c2175 Polish My Badges tab: fix scroll areas, overflow menus, clean up chrome
- Fix ScrollArea to use fixed h-[24rem] with content inside (loading,
  empty, list) matching the lief sticker management pattern
- Remove background boxes from all badge rows and scroll containers
- Remove glowing border from pending badge area
- Extract shared BadgeOverflowMenu with View link for all badges,
  Award/Edit/Delete only shown for badges you created
- Replace inline action buttons on created badges with overflow menu
- Add rounded-full hover on pending nav arrows, strokeWidth 4
- Remove redundant New Badge button from Created section (FAB exists)
2026-04-01 02:29:56 -05:00
Chad Curtis a570b318d7 Revamp My Badges tab: draggable badge order, scroll areas, and pending carousel
- Extract reusable SortableList/SortableItem components sharing the same
  @dnd-kit pattern used by the sidebar edit view (DRY)
- Replace ChevronUp/ChevronDown reorder buttons with drag-and-drop on
  accepted badges list
- Wrap accepted and created badge sections in ScrollArea (max-h 420px)
- Redesign pending badges as a carousel showing one badge at a time in
  the notification-style BadgeContent presentation (rotating rays, 3D
  tilt), with left/right arrows to navigate the pending queue
2026-04-01 02:16:14 -05:00
Chad Curtis 99e32d9491 Fix followers/following modal staying open after navigating to a profile 2026-04-01 02:01:28 -05:00
Chad Curtis 74022e8181 Render full reply threads on post detail page
Replace the flat reply list (showing one sub-reply hint per reply) with
a recursive threaded tree on the post detail page. Threads deeper than
3 levels collapse behind a 'Show N more replies' button that expands
the subtree in-place.

Also fix useReplies to fetch iteratively — some clients only tag the
immediate parent in e-tags, not the thread root, so a single query
misses deeper replies. The hook now discovers the full tree by querying
for replies to each new batch of event IDs (up to 5 rounds).

Other pages (profile wall, external content, badges) keep the existing
flat preview via FlatThreadedReplyList.
2026-04-01 01:55:01 -05:00
Alex Gleason d0b5164e6d Deploy nsite as named site 'ditto' and drop publish-relay-list 2026-03-31 21:01:47 -05:00
Alex Gleason defc39c0f3 Replace GitLab Pages deploy with nsite deploy via nsyte
Use nsyte CLI with NIP-46 nbunksec bunker credential to deploy
the web app to nsite on every default branch push. Downloads the
nsyte binary, builds the Vite app, and uploads to configured
Blossom servers and Nostr relays with SPA fallback routing.
2026-03-31 20:29:29 -05:00
filemon a9844b3a4f fix(lint): remove unused gaze state variable in useBlobbiCompanionGaze 2026-03-31 22:12:25 -03:00
Alex Gleason 77b8498850 npm audix fix 2026-03-31 20:00:22 -05:00
filemon 4c34aba66d fix(blobbi-page): restore page-specific sleep toggle and sleeping recipe overlay
Part A — Restore BlobbiPage handleRest:
- Revert handleRest to the original blobbi-specific implementation that
  operates on the page-selected companion (via selectedD/companionsByD),
  not profile.currentCompanion. This ensures the BlobbiActionsModal
  sleep/wake button targets the correct Blobbi.
- The companion floating button continues to use useBlobbiSleepToggle
  independently (targets profile.currentCompanion). These are separate
  and correct targets for their respective contexts.
- Restore imports: KIND_BLOBBI_STATE, updateBlobbiTags, applyBlobbiDecay,
  trackDailyMissionProgress, getStreakTagUpdates.

Part B — Apply sleeping recipe overlay on BlobbiPage:
- Keep useStatusReaction enabled during sleep (was disabled with
  enabled: !isSleeping). Body effects (dirty, stink) and extras (food
  icon) still resolve while sleeping.
- Apply buildSleepingRecipe(rawStatusRecipe) when isSleeping is true,
  same pattern as BlobbiCompanionLayer. This overlays closed eyes,
  sleeping mouth, and Zzz while preserving compatible status effects.
- Suppress actionOverride during sleep (no happy/excited flash).
- Remove opacity-80 dim on sleeping Blobbi container (sleeping visuals
  are now expressed through the recipe, not opacity).

Part D — Sleepy vs sleeping verified:
- sleepyBlink (drowsy cycling animation) and sleepingClosed (permanent
  eye closure) are separate EyeRecipe fields that never overlap.
- buildSleepingRecipe never sets sleepyBlink; status reactions never
  set sleepingClosed. The guard in applyVisualRecipe (line 894) skips
  sleepingClosed when sleepyBlink is present, but this case never
  occurs in practice.
2026-03-31 21:58:23 -03:00
filemon 2bf4ed2af8 Merge branch 'main' into implement-reactions-clean 2026-03-31 21:45:48 -03:00
filemon 5afeac3c14 refactor(sleeping): recipe overlay instead of SVG swap, unify sleep paths
Part A — Remove sleeping SVG asset swap:
- Both renderers now always use the base (awake) SVG and run the full
  visual pipeline (eye animation, recipe, body effects). The isSleeping
  gate and sleeping SVG variant selection are removed.
- Sleeping visuals are achieved through the recipe system: permanently
  closed eyes via clip-path closure, closed-eye line overlays, sleeping
  mouth, and animated Zzz — all injected by applySleepingClosedEyes()
  in applyVisualRecipe() when recipe.eyes.sleepingClosed is set.
- Delete sleeping-animation.ts (dead code from previous approach).
- Remove opacity-70 dim on sleeping containers.

Part B — Sleeping as recipe overlay with selective coexistence:
- Add sleepingClosed field to EyeRecipe for permanently closed eyes
- Add buildSleepingRecipe() that takes a status recipe and produces a
  sleeping variant: overrides eyes/mouth/eyebrows, preserves body
  effects (dirty smudges, stink clouds) and food icon, strips drool/
  tears/watery eyes/dizzy spirals
- BlobbiCompanionLayer keeps useStatusReaction enabled during sleep
  (was previously disabled), applies buildSleepingRecipe overlay on
  top so body effects still render while the face shows sleeping state
- Action overrides are suppressed during sleep

Part C — Unify sleep action paths:
- BlobbiPage.handleRest now delegates to useBlobbiSleepToggle (same
  hook used by the companion radial menu), ensuring identical event
  publish, cache update, and companion state propagation regardless
  of which UI triggers sleep
- Fix useBlobbiSleepToggle cache update: use getQueriesData with
  partial key matching to find all blobbi-collection cache entries
  for the user, then setQueryData on each with exact keys. This
  ensures the optimistic update reaches the correct cache entry
  that useBlobbiCompanionData reads from
- Remove unused imports from BlobbiPage (KIND_BLOBBI_STATE,
  updateBlobbiTags, applyBlobbiDecay, trackDailyMissionProgress,
  getStreakTagUpdates) that were only used by the old handleRest
2026-03-31 21:32:18 -03:00
filemon 39e3c0b30f fix(companion): implement sleeping visuals, standalone sleep action, fix state types
Part A — Sleeping visuals:
- Add sleeping-animation.ts with CSS keyframe animations for the
  pre-baked sleeping SVG assets: Zzz text floats with staggered delays,
  body gently breathes via scaleY pulse
- Both BlobbiBabySvgRenderer and BlobbiAdultSvgRenderer now call
  applySleepingAnimation() in the isSleeping path instead of returning
  the raw static colorizedSvg

Part B — State propagation:
- Tighten CompanionData.state from 'string | undefined' to
  'BlobbiState | undefined' so the sleeping state is type-safe through
  the full chain: parseBlobbiEvent -> useBlobbisCollection ->
  useBlobbiCompanionData -> companionDataToBlobbi -> SVG renderers
- Remove the unnecessary 'as BlobbiState' cast in the adapter now that
  CompanionData.state is properly typed

Part C — Standalone companion sleep action:
- Add useBlobbiSleepToggle hook that independently fetches fresh event
  data from relays, applies decay, publishes the state change, and
  optimistically updates the TanStack cache. Works on any page without
  BlobbiPage being mounted
- Remove the toggleSleep registration plumbing from BlobbiActionsProvider
  and BlobbiActionsContext (ToggleSleepFunction type, toggleSleepRef,
  third parameter on useBlobbiActionsRegistration)
- BlobbiCompanionLayer now uses useBlobbiSleepToggle directly instead
  of reading toggleSleep from useBlobbiActions context
2026-03-31 20:47:00 -03:00
Alex Gleason d749718584 release: v2.2.7 2026-03-31 17:48:17 -05:00
Alex Gleason 922a66835a Fix Nushu script not rendering on Android by bundling Noto Sans Nushu font
Android system fonts don't include glyphs for the Nushu Unicode block
(U+1B170-U+1B2FF), causing the encrypted letter ciphertext to render as
empty boxes. Bundle @fontsource/noto-sans-nushu as a web font so the
glyphs render correctly on all platforms.
2026-03-31 17:44:30 -05:00
Alex Gleason 0d4a96e785 Fix zapstore publish: replace removed -y flag with --quiet
zsp v0.4.5 renamed the -y flag to --quiet. The old flag caused
the publish command to fail silently (exit 0 with usage printed
to stderr), so the CI job appeared to succeed.
2026-03-31 17:23:22 -05:00
Alex Gleason a3e10bc12b release: v2.2.6 2026-03-31 16:52:20 -05:00
Alex Gleason 49c482f2ba Allow scrolling Nushu text when container is too small
Switch from overflow-hidden to overflow-y-auto so the ciphertext can
be scrolled on small screens. The fade gradient becomes sticky so it
stays at the bottom of the visible area as a scroll hint.
2026-03-31 16:48:35 -05:00
Alex Gleason 0ad7a7892b Fix encryption notice overflow on mobile by truncating Nushu text
Make the inner letter sheet a flex column so the decorative rule and
'This message is encrypted' notice are always visible (shrink-0). The
Nushu text area takes the remaining space (flex-1 min-h-0 overflow-hidden)
with a bottom fade-out gradient mask when it overflows.
2026-03-31 16:46:46 -05:00
Alex Gleason 989b423714 Map base64 ciphertext 1:1 to first 64 Nushu characters
Each base64 symbol maps directly to a Nushu codepoint (U+1B170-1B1AF),
preserving the same information density as the original encoding rather
than reducing through an arbitrary modulo.
2026-03-31 16:44:30 -05:00
Alex Gleason 13f703a3ec Remove mail icon from envelope front face for cleaner sealed state 2026-03-31 16:41:08 -05:00
Alex Gleason aa7c8e038b Refine encrypted letter envelope: 3D tilt, curated Nushu, sizing
- Add useCardTilt hook for badge-style 3D hover/touch tilt effect
- Constrain envelope with max-w-md, centered with horizontal padding
- Replace dense Nushu encoding with curated set of simpler characters
  spaced with thin spaces for an elegant, sparse look
- Remove all hint text (flip/open/close) to invite curiosity instead
- Add 'This message is encrypted' with lock icon on the open state
- Use Lock icon import for the encryption notice
2026-03-31 16:38:56 -05:00
Alex Gleason 0469b6cec9 Display encrypted letters as interactive 3D envelopes with Nushu ciphertext
Register kind 8211 across the event rendering pipeline so encrypted
letters render as 3D interactive envelopes instead of raw ciphertext.
Back shows a sealed envelope with sender/recipient names in script font
and a wax seal avatar. Click flips the envelope (CSS 3D transform),
click again opens it to reveal the ciphertext rendered as Nushu
characters -- a real historical secret women's script from China.
2026-03-31 16:32:27 -05:00
filemon ef88ca4235 feat(companion): implement proper sleep/wake state, fix mobile tap interaction
- Fix sleep visuals on floating companion: companionDataToBlobbi adapter
  now passes through actual state and isSleeping instead of hardcoding
  'active'/false, so sleeping Blobbi renders closed eyes and Zzz

- Refactor companion sleep button as direct action: sleep/wake toggle
  is routed through BlobbiActionsProvider (toggleSleep registration)
  instead of the item-flow system. Companion menu button shows Wake up
  (sun emoji) when sleeping, Sleep (moon emoji) when awake

- Freeze companion movement during sleep: state machine respects
  isSleeping flag, clears all timers/targets, forces idle state.
  Float animation and sway CSS animation also disabled while sleeping.
  Blobbi stays parked exactly where sleep was triggered

- Fix mobile tap on companion: remove duplicate touch event handlers
  (touchstart/touchmove/touchend) that conflicted with pointer events.
  Pointer events handle mouse+touch+pen natively. Use containerRef for
  setPointerCapture instead of e.target for reliable cross-platform
  tracking. Remove preventDefault from pointerdown to avoid blocking
  browser touch-to-pointer synthesis
2026-03-31 12:32:48 -03:00
Chad Curtis 1adbe1c98a fix: add /remoteloginsuccess route for remote signer callback 2026-03-31 08:33:11 -05:00
Chad Curtis b97299ce0a fix: remove unused user and useCurrentUser left over from PostActionBar extraction 2026-03-31 08:25:58 -05:00
Chad Curtis 93eeffb1ad fix: remove dead ZapDialog and canZapAuthor imports left over from PostActionBar extraction 2026-03-31 08:22:14 -05:00
Chad Curtis 081ad9240f fix: move zap comment inside right column instead of pl-[52px] offset 2026-03-31 08:19:47 -05:00
Chad Curtis 7d3b92048b fix: load letter fonts so font picker options render in correct typeface 2026-03-31 07:50:38 -05:00
Chad Curtis 3c425a4e68 fix: badge detail and my badges UX improvements
- Move Award to… button inline with awarded count, right-aligned, styled as pill
- Accept Badge action moved to its own row below stats
- Always show organize buttons (move up/down, remove) on mobile in My Badges list
- Use Trash2 icon instead of X for remove badge button
2026-03-31 07:45:29 -05:00
Chad Curtis 4ae90080e8 refactor: extract PostActionBar and unify badge detail tab bar
- Extract shared PostActionBar component used by both PostDetailPage and BadgeDetailContent
- Replace badge detail inline reaction bar with PostActionBar (removes copy button, adds share + more)
- Replace badge detail hand-rolled sticky tab div with SubHeaderBar (pinned) for arc style and hide-on-scroll behaviour
- Add ARC_OVERHANG_PX spacer above tab content
2026-03-31 07:37:44 -05:00
Chad Curtis 2cdcd543a4 fix: only show safe-area padding on pinned SubHeaderBar when at top of viewport
Previously the safe-area padding was tied to navHidden, which fires after
just 8px of scroll — causing the spacer above profile tabs to appear while
the bar was still mid-page. Now a scroll listener checks the bar's actual
getBoundingClientRect().top against the measured safe-area-inset-top, so
the padding only appears once the bar has physically reached the top.
2026-03-31 07:21:23 -05:00
Chad Curtis 71f8ee0e16 fix: support accented and Unicode characters in hashtags
Replace /#\w+/g with /#[\p{L}\p{N}_]+/gu across all hashtag regexes
so that hashtags like #Bíblia and #verdade parse correctly. Affects
NoteContent, BioContent, ComposeBox, and PhotoComposeModal.
2026-03-31 06:48:36 -05:00
Chad Curtis 92634705b3 fix: notifications reply button navigates to /letters/compose instead of in-page 2026-03-31 06:45:10 -05:00
Chad Curtis 7aee4fe712 fix: navigate to /letters/compose instead of opening compose in-page
Reply button and FAB on LettersPage now navigate to the dedicated
/letters/compose route. The ?to= query param pre-fills the recipient
when replying to a received letter.
2026-03-31 05:31:23 -05:00
filemon 0e4ce974f0 fix(companion): remove baseLift from walking float offset
Complement to the wrapper-split fix — also remove the baseLift = -2
constant that biased the walking Y offset permanently upward.
2026-03-31 06:06:17 -03:00
filemon 4ddcee95d9 fix(companion): split float and sway wrappers to fix walking ground gap
Root cause: the CSS animation `animate-blobbi-sway` (blobbi-gentle-sway
keyframes) sets `transform: rotate(-2deg)` which **replaces** the entire
inline `transform` on the same element while the animation is active.
This dropped the `translateY(size * 0.12)` alignment shift (~13px) that
anchors the body to the ground, causing Blobbi to float above the shadow
during walking.

Fix: split the single wrapper into two nested divs:

  Float wrapper (outer): owns translateY + JS float offset (inline transform)
  Sway wrapper (inner):  owns CSS rotation animation only

The CSS keyframes now only override the sway wrapper's transform (which
has no positioning), while the float wrapper's translateY and float
offset remain unaffected. The SVG subtree stability is preserved —
MemoizedBlobbiVisual stays inside the sway wrapper with no changes.
2026-03-31 05:56:48 -03:00
filemon 4e1f7b6007 fix(blobbi): move flies to lower body, use soft smile for hungry mouth
Flies: Reposition all fly orbits (baby + adult) to the lower third of
the body, well below the face region. Orbits are tighter so flies stay
near the grimy lower body / feet area and never overlap eyes or mouth.

Hungry mouth: Replace round 'O' mouth with smallSmile at warning/high
severity. The round mouth read as surprise rather than hunger. A soft
smile pairs naturally with hopeful eyes and drool, reading as 'please
feed me'. Critical hunger still uses droopyMouth for the desperate
state. The priority system is unchanged — if another stat with higher
mouth priority contributes a round mouth, that still wins.
2026-03-31 05:38:58 -03:00
filemon 00f3deb5b2 redesign(blobbi): overhaul dirty effect with muddy smudges, odor wisps, and flies
Replace visually weak dirt marks with a deliberate, cartoon-style
dirty effect inspired by Tamagotchi/Pokémon status readability.

Dirt layer (on-body):
- Organic muddy smudge blobs in warm brown palette (replaces thin strokes)
- Small grime spots clustered near smudges for texture depth
- Subtle dusty haze patches for grimy tinting
- New intensity parameter controls opacity/density per severity

Smell layer (off-body):
- Wavy S-curve stink lines in muted sage green (replaces tiny clouds)
- Soft puff cloudlets with rise-and-fade animation
- Optional buzzing flies on elliptical orbits (critical severity only)
- Wisps are placed at body sides/top so they read as emanating outward

Severity escalation:
- Warning: light smudges (intensity 0.45), 2 faint wisps
- High: heavier grime (0.65), 3 wisps
- Critical: heavy grime (0.80), 4 wisps + 2 buzzing flies
2026-03-31 05:34:44 -03:00
filemon b8037c48e9 chore(blobbi): final validation pass — tighten comments, remove blank line noise
Regression review confirmed all flows intact. Minimal cleanup:
- Remove stray blank lines in SvgRenderer components
- Condense useExternalEyeOffset inline comments (replaced verbose
  per-line explanations with concise section labels)
2026-03-31 05:03:34 -03:00
filemon a3dfe25d13 Merge branch 'main' into implement-reactions-clean 2026-03-31 04:56:59 -03:00
filemon 50a834c4fc refactor(companion): reduce BlobbiCompanionLayer responsibility and remove dead gaze state
Cleanup pass with zero behavior changes.

Extracted from BlobbiCompanionLayer:
- DebugGroundOverlay: 76-line debug overlay moved to its own component
- useActionEmotionOverride: action emotion state + timer logic extracted
  into a focused hook, replacing the inline state/setTimeout/wrapper pattern

Removed dead code:
- gaze state from useBlobbiCompanionGaze return (internal state preserved
  for the hook's own mode-selection logic; only the unused external return
  field removed)
- gaze field from useBlobbiCompanion return and UseBlobbiCompanionResult
- GazeState import from useBlobbiCompanion (no longer in return type)
- gaze field from CompanionContextValue type (unused interface)
- companionRecipeProp / companionRecipeLabelProp identity aliases
- originalHandleItemUse unnecessary alias
- handleItemUseWithEmotion wrapper (replaced by inline triggerOverride call)

Clarified:
- BlobbiCompanionLayer docblock explains its orchestration-only role
- Section comments organize the wiring concerns (item reaction, action
  menu, item use, status reaction, render)
2026-03-31 04:31:47 -03:00
filemon f00332fca5 chore(blobbi): cleanup dead code and add architectural contracts
Stabilization pass — zero behavior changes.

Dead code removed:
- Unused containerRef in both SvgRenderer components (parent wrapper
  owns the DOM query boundary for eye hooks, not the renderer)
- Unused containerRef in BlobbiCompanionVisual
- Dead eyeOffset React state from useBlobbiCompanionGaze (only the ref
  is used now; the state was never updated after the ref-based fix)
- Dead eyeOffset value from useBlobbiCompanion return and
  BlobbiCompanionLayer destructure
- Deprecated AdultReactionState / BabyReactionState type aliases
  (no consumers)
- Deprecated ExternalEyeOffset re-exports from visual wrappers
  (canonical export is lib/types.ts)
- Stale JSDoc comment about containerRef forwarding in renderer

Contract comments added:
- SvgRenderer components: explicit MUST NOT list (no hooks, no modes,
  no reaction classes)
- Visual wrapper containerRef: explains it is the DOM query boundary
  for eye hooks
- MemoizedBlobbiVisual: stability contract listing what it must and
  must not depend on
- useExternalEyeOffset: clarified page vs companion usage for each
  offset prop
- BlobbiCompanionVisual direction prop: documented why it exists unused
2026-03-31 04:19:10 -03:00
filemon 384936f106 refactor(blobbi): extract pure SVG renderers and add explicit render mode
Architecture refactor for the Blobbi visual system:

1. Centralized debug helper (src/blobbi/ui/lib/debug.ts):
   - Replaces all scattered console.log/trace instrumentation
   - Single BLOBBI_DEBUG flag, only logs in DEV mode when enabled
   - Typed debug categories for filtering

2. Explicit render mode API (BlobbiRenderMode: 'page' | 'companion'):
   - Replaces implicit companion detection via eye offset prop sniffing
   - Controls tracking, reaction class suppression, and future behaviors
   - Default is 'page' — no changes needed for existing BlobbiPage callers

3. Pure SVG renderer extraction:
   - BlobbiAdultSvgRenderer: resolve → customize → animate → recipe → sanitize → innerHTML
   - BlobbiBabySvgRenderer: same pipeline for baby stage
   - These components know nothing about hooks, modes, or runtime state
   - Only rerender when visual content changes (blobbi, recipe, emotion, bodyEffects)

4. Visual wrappers simplified:
   - BlobbiAdultVisual/BlobbiBabyVisual own the containerRef, eye hooks,
     and reaction CSS classes — delegate SVG output to the renderers
   - ~480 lines removed across the visual layer

Net result: -305 lines, zero debug console spam, clean separation between
SVG pipeline, eye behavior, and companion runtime.
2026-03-31 03:53:17 -03:00
filemon 81966dac0d fix(companion): stabilize SVG DOM to prevent animation restarts
The companion rerender storm (~46 renders/2s from RAF loops) was causing
the animated SVG subtree to be replaced on every render, killing SMIL
and CSS animations (dizzy spirals, sleepy Zzz, etc.).

Three root causes fixed:

1. Ref-based gaze: eyeOffset was React state updated every frame in
   useBlobbiCompanionGaze, propagating rerenders through the entire
   companion tree. Now writes to a ref that useExternalEyeOffset reads
   imperatively via its own RAF loop — zero React rerenders for gaze.

2. Memoized SVG renderer: created MemoizedBlobbiVisual (React.memo)
   that only rerenders when visual content changes (blobbi, recipe,
   emotion, bodyEffects). Reaction CSS classes (sway/bounce) moved to
   an outer wrapper div in BlobbiCompanionVisual so className changes
   don't touch the dangerouslySetInnerHTML container.

3. Stable recipe references: resolveStatusRecipe() returned fresh {}
   objects for neutral state, defeating memo comparators. Now uses
   shared frozen EMPTY_RECIPE and NEUTRAL_STATUS_RESULT constants.
2026-03-31 03:39:06 -03:00
Chad Curtis 7c8e4f1735 Remove unused EventStats type import in PostDetailPage 2026-03-31 00:34:35 -05:00
Chad Curtis b9b9363468 Render kind 9735 zap receipts and kind 0 profiles in feed and detail pages
- Kind 9735: feed and detail cards mirror the reaction card layout exactly — zap icon bubble, sender avatar/name, 'zapped N sat(s)', timestamp (ml-auto), message indented under profile on the line below. Threaded variant included. Zap rows in InteractionsModal now link to the receipt nevent. ZapEntry gains eventId field.
- Kind 0: feed card renders ProfileCard inline; detail page renders ProfileCard directly with no action header
- CommentContext: add kind 0 (profile) and 9735 (zap) to KIND_LABELS and KIND_ICONS
- NoteCard KIND_HEADER_MAP: add kind 9735 zap header
- shellTitleForKind: 'Zap' for 9735, 'Profile' for 0
2026-03-31 00:24:02 -05:00
Chad Curtis 11ecfb1bcf Render kind 9735 zap receipts and kind 0 profiles in feed and detail pages
- Kind 9735: feed card shows sender, amount (pluralized sat/sats), message; detail page shows activity-style card with ancestor thread; zap rows in InteractionsModal now link to the receipt nevent instead of sender npub, and include eventId on ZapEntry
- Kind 0: feed card renders ProfileCard inline; detail page renders ProfileCard directly with no action header
- CommentContext: add kind 0 (profile) and 9735 (zap) to KIND_LABELS and KIND_ICONS
- NoteCard KIND_HEADER_MAP: add kind 9735 zap header
2026-03-30 23:53:57 -05:00
Alex Gleason 605f4e52fe release: v2.2.5 2026-03-30 22:53:56 -05:00
Alex Gleason a45e649374 Fix infinite re-render crash when dragging profile tabs in edit mode
Remove 'transform' from useLayoutEffect deps in SortableTabChip. During
a drag, useSortable produces a new transform object every frame, which
triggered onActive() -> SubHeaderBar re-render -> new transform ref ->
effect re-fires, causing React error #185 (maximum update depth exceeded).
The active indicator position only needs to update when the active tab
changes, not on every drag frame.
2026-03-30 22:48:31 -05:00
filemon 3f32c95b35 fix(blobbi): update eye helpers for nested gaze group structure
The eye injection and detection modules were broken after adding the
nested .blobbi-eye-gaze group. The issues were:

1. modifyEyeGroupContent used indexOf('</g>') which found the gaze
   group's closing tag instead of the eye group's, breaking effects
   that modify pupil/highlight content

2. injectIntoEyeTrackLayer used a naive regex that didn't handle
   nested groups

3. detectFromProcessedSvg had a rigid regex that required exact
   class order and couldn't handle the new nested structure

Fixes:
- Added findMatchingCloseTag() for balanced group parsing
- Added findGroupByClass() helper for finding group boundaries
- Updated modifyEyeGroupContent to target .blobbi-eye-gaze (innermost)
- Updated injectIntoEyeTrackLayer to use balanced parsing
- Updated detectFromProcessedSvg with flexible class matching
- Updated documentation to reflect new 3-layer eye structure:
  1. .blobbi-blink (outer) - clip-path for eyelid animation
  2. .blobbi-eye (middle) - CSS animations like sleepy wake-glance
  3. .blobbi-eye-gaze (inner) - gaze tracking transforms

This allows eye effects (sad highlights, star eyes, etc.) to work
correctly with the new gaze/animation layer separation.
2026-03-30 19:50:44 -03:00
Alex Gleason 2919bdf691 release: v2.2.4 2026-03-30 17:22:15 -05:00
filemon 6192dfc568 refactor(blobbi): separate eye animation and gaze layers to prevent conflicts
Previously, both CSS animations (like sleepy wake-glance) and JS gaze
tracking targeted the same .blobbi-eye group. This caused conflicts where
external gaze had to disable CSS animations to control the transform.

Now the eye structure has three layers:
1. .blobbi-blink (outer) - clip-path for eyelid/blink animation
2. .blobbi-eye (middle) - CSS animations like sleepy wake-glance
3. .blobbi-eye-gaze (inner) - JS-controlled translate for gaze tracking

This separation allows:
- Sleepy's wake-glance CSS animation to run on .blobbi-eye
- External gaze and mouse tracking to control .blobbi-eye-gaze
- Both effects to work together without disabling either
- Eyelid clip-path animation to remain independent

Changes:
- eye-animation.ts: Added nested .blobbi-eye-gaze group inside .blobbi-eye
- eyes/types.ts: Added gazeLeft, gazeRight, gaze to EYE_CLASSES
- useBlobbiEyes.ts: Updated to target .blobbi-eye-gaze for tracking
- useExternalEyeOffset.ts: Updated to target .blobbi-eye-gaze, removed
  animation disabling hack
2026-03-30 19:19:56 -03:00
filemon de57399301 Merge branch 'main' into implement-reactions-clean 2026-03-30 19:11:31 -03:00
filemon c6e6326b50 fix(blobbi): allow external gaze to override sleepy eye animation
The sleepy emotion uses a CSS animation (sleepy-wake-glance) on .blobbi-eye
elements that applies transform: translateX() for a periodic side-glance.
Previously, useExternalEyeOffset detected this animation and yielded to it,
causing eyes to stop tracking gaze entirely during sleepy.

Now, when external gaze is active and a CSS animation is detected on eye
elements, we disable the animation and take control of the transform. This
allows:

- External gaze tracking to work during sleepy emotion
- Sleepy's eyelid closing animation (SMIL on clip-path) to continue
- The drowsy heavy-lidded effect to layer with gaze tracking

The key insight is that sleepy has two visual effects:
1. Eye position animation (CSS transform) - now disabled for external gaze
2. Eyelid closing animation (SMIL clip-path) - preserved for drowsy look
2026-03-30 19:06:49 -03:00
Alex Gleason cf59b6d0da Fix crash on /notifications from malformed badge award a-tags
Validate that the pubkey extracted from a kind 8 badge award event's
a-tag is a valid 64-char hex string before passing it to
nip19.naddrEncode(). Malformed pubkeys (from permissionless Nostr
events) caused hexToBytes() to throw 'Invalid byte sequence'.
2026-03-30 16:57:25 -05:00
filemon d836b1f068 fix(blobbi): use RAF loop in useExternalEyeOffset to prevent stuck eyes
The previous useEffect-based approach only applied eye transforms when the
externalEyeOffset prop changed. This caused eyes to get stuck in the center
when the companion was idle because:

- The gaze RAF loop updates eyeOffset state continuously
- React batches state updates, so re-renders may not happen every frame
- useBlobbiEyes also runs a RAF loop for blinking that could interfere
- SVG content changes (emotion recipes) could reset transforms

The fix uses a RAF loop that continuously applies the transform, reading
the latest offset from a ref. This ensures eyes stay positioned correctly
regardless of React render timing or SVG DOM changes.

Dragging previously 'fixed' the stuck eyes because isDragging changes
caused guaranteed re-renders that triggered the old useEffect.
2026-03-30 18:38:23 -03:00
Alex Gleason 6071a28dd9 Include all badge awards in deletion request when deleting a badge definition 2026-03-30 16:28:07 -05:00
filemon 03fa16ded2 fix(blobbi): fix companion motion/state desync and gaze RAF instability
BUGS FIXED:

1. Motion/State Desynchronization
   - useBlobbiCompanionState was receiving a hardcoded static motion object
     instead of real live motion data from useBlobbiCompanionMotion
   - This caused state decisions (walking, idle, observation) to use stale
     position/dragging data, desyncing behavior from rendered position
   - FIX: Introduce shared motionRef that motion hook writes and state hook
     reads, solving the bidirectional dependency cleanly

2. Gaze Animation Loop Instability
   - The RAF effect for smooth eye movement depended on companionPosition,
     mousePosition, observationTarget, attentionPosition, entryInspectionDirection
   - Every position change caused the loop to be torn down and recreated
   - This caused jitter during movement and stuck eyes after entry animation
   - FIX: Use refs for all frequently-changing values, only depend on isActive
     to start/stop the loop. Loop reads fresh values from refs each frame.

3. Drag Detection in State Hook
   - Changed from motion.isDragging dependency (no longer available) to
     polling motionRef.current.isDragging via interval since refs don't
     trigger re-renders

ARCHITECTURE CHANGES:
- useBlobbiCompanion: Creates shared motionRef, passes to state and motion hooks
- useBlobbiCompanionMotion: Accepts optional sharedMotionRef, syncs motion to it
- useBlobbiCompanionState: Receives motionRef instead of motion object
- useBlobbiCompanionGaze: Uses refs for position/target values, stable RAF loop
2026-03-30 18:14:14 -03:00
Alex Gleason b8eb0a8549 Filter out malformed custom emoji reactions missing the emoji tag 2026-03-30 15:49:25 -05:00
filemon 5dac0214ea fix(blobbi): improve companion reaction animations and remove duplicate hooks
- Fix reaction logic in BlobbiCompanionVisual: now uses 'swaying' when walking
  instead of always returning 'idle' (previously dead code)
- Remove duplicate useBlobbiCompanion hooks from src/hooks/ and src/blobbi/core/hooks/
  (orphaned files, not imported anywhere)
- Verified lookMode='forward' does NOT block externalEyeOffset - eye tracking
  system correctly uses external offset when disableTracking is true
2026-03-30 16:57:01 -03:00
filemon 3ddb7c8ceb Merge branch 'main' into implement-reactions-clean 2026-03-30 16:35:29 -03:00
Alex Gleason 03d4b6c4f2 Include both 'e' and 'a' tags in deletion events for addressable events
NIP-09 deletion events for addressable events (kinds 30000-39999) now
include both an 'e' tag (event ID) and an 'a' tag (event coordinate)
to ensure deletion works on relays that only support one or the other.

- useDeleteEvent: accept optional pubkey/dTag params, auto-add 'a' tag
  for addressable kinds
- NoteMoreMenu: pass event pubkey and d-tag to useDeleteEvent
- BadgesPage: add missing 'e' tag to badge definition deletion
- useUserLists: add missing 'e' tag to list deletion
2026-03-30 13:47:05 -05:00
filemon 55b551f214 fix(blobbi): companion now updates reactively when stats change
ROOT CAUSE:
The companion layer was not updating live because:
1. It used a separate query ('companion-blobbi') that wasn't optimistically updated
2. It didn't use projected state, so it showed raw relay data without decay
3. Item use only invalidated queries without optimistic updates, causing relay latency

FIXES:
1. useBlobbiCompanionData now uses useBlobbisCollection (shared with BlobbiPage)
   - Shares the same query cache that gets optimistic updates
   - No longer has a separate stale query

2. useBlobbiCompanionData now applies projected state via useProjectedBlobbiState
   - Companion shows projected decay (recalculates every 60 seconds)
   - Same behavior as BlobbiPage

3. useBlobbiItemUse now optimistically updates the blobbi-collection cache
   - Uses setQueryData to immediately update the parsed companion
   - Companion visual updates instantly after actions
   - Also invalidates for background consistency check

DATA FLOW (after fix):
1. User performs action → useBlobbiItemUse publishes event
2. Optimistic update → setQueryData updates blobbi-collection cache
3. useBlobbisCollection returns new data → blobbi reference changes
4. useProjectedBlobbiState recalculates → projectedState changes
5. useBlobbiCompanionData creates new companion object with new stats
6. BlobbiCompanionLayer's companionStats memo recalculates
7. useStatusReaction sees new stats → resolves new recipe
8. Visual updates immediately

NO REMOUNT KEY NEEDED:
The fix works purely through React's normal reactivity:
- Object references change through the memo chain
- useStatusReaction's effect detects stat changes via reference comparison
- No forced remounts required
2026-03-30 15:38:53 -03:00
Alex Gleason 6fe17c1cfd Fix notifications collapsing multiple profile reactions from the same user
Profile reactions (kind 7 on kind 0) are intentionally allowed to be
multiple, unlike post reactions. Treat each profile reaction as a
standalone notification instead of grouping by referenced event ID,
which was causing only the latest reaction per user to be shown.
2026-03-30 13:19:00 -05:00
filemon 93ccb572e5 fix(blobbi): alternating spiral winding, body-aware food icon positioning
- Fix egg sick spiral winding: Inner 3 now uses clockwise=false for
  proper alternation across all 7 spirals (4 outer + 3 inner)
- Add body-aware food icon positioning for adults using detectBodyPath()
- Food icon now placed at upper-left relative to detected body bounds
- Update FoodIconConfig type to accept bodyPath for shape-aware placement
- Import detectBodyPath in recipe.ts for food icon positioning
2026-03-30 15:15:34 -03:00
Alex Gleason 03aa1e6dbc Fix oversized reaction emoji in comment context header 2026-03-30 13:03:25 -05:00
filemon 059fb67d26 fix(blobbi): layered egg spirals, natural adult dirt distribution, adult food icon
## Egg spiral layering:
- Added 7 spirals total (4 outer + 3 inner) for magical/dizzy effect
- Outer spirals: float around egg shell at varying distances
- Inner spirals: subtle spirals across the egg body itself
- Mixed colors: gray (#4b5563, #6b7280, #9ca3af) + white accents
- Varying sizes (0.45em to 1.1em), speeds (2s to 4s), directions
- All use true Archimedean spiral paths matching Blobbi dizzy eyes
- Counter-clockwise on left side, clockwise on right for visual balance

## Adult dirt distribution:
- Uses detected body bounds for natural placement
- Distributes across multiple zones (not clustered in center or edges):
  - Lower-left edge (primary)
  - Lower-right edge (primary)
  - Left-center lower area (secondary)
  - Right mid-lower contour (fill)
  - Left side contour (fill)
- Face region ends at 55% body height (all dirt below that)
- Mark length scales with body width (6% of width, min 3 units)

## Adult food icon position/size:
- Position: upper-left (x=55, y=45) instead of upper-right
- Size: 80% larger (scale=1.8) for better visibility
- Stroke width increased proportionally (1.5x)
- Higher opacity (0.75) vs baby (0.65)
- Baby unchanged: upper-right position, original size
2026-03-30 14:57:40 -03:00
filemon eec7f1d5b5 fix(blobbi): body-aware dirt placement, dizzy-style spirals, stronger front dust
## Adult dirt placement now uses real body silhouette:
- detectBodyPath() extracts full X/Y bounds from SVG path
- computeAdultDirtPositions() places marks relative to actual body
- Dirt at lower 35% of body height, near side edges
- Scales with body size (mark length = 5% of width)
- Fallback to conservative defaults if body not detected

## Egg spirals now match dizzy eye visual language:
- Uses same createSpiralPath() Archimedean spiral algorithm
- SVG-native animateTransform rotation (not CSS)
- Dark stroke color (#1f2937) matching dizzy eyes
- Positioned floating around egg, not inside shell
- Varying sizes and rotation speeds for visual interest

## Front/back dust distribution:
- Egg: 2 back particles below, 4 front particles at lower edges
- Baby: 3 back below body, 3 front at lower side edges
- Adult: 3 back below body, 3 front at lower edges (body-aware)
- Front dust: larger, higher opacity (0.75-0.8), darker color
- Back dust: smaller, lower opacity (0.55), lighter color
- All dust avoids face region, stays at lower body edges
2026-03-30 14:42:43 -03:00
filemon 5ab16fbbf3 fix(blobbi): reposition dirt marks to lower body edges, avoid facial zones
Protected zones (dirt marks NEVER appear here):
- Eyes, mouth, eyebrows
- Tears, saliva/drool, blush marks, sparkles
- Upper-center body area where face elements live

Preferred dirt placement zones:
- Lower-left edge of body silhouette
- Lower-right edge of body silhouette
- Bottom edge (well below face region)

Variant differences:
- Egg: dust at lower outer shell edges only, no center-front placement
- Baby (100x100): safe zone y > 72, prefer x < 35 or x > 65
- Adult (200x200): safe zone y > 120, prefer x < 85 or x > 115

Also updated dust particle positions to follow same rules.
2026-03-30 14:30:37 -03:00
filemon a74f7037ff fix(blobbi): variant-aware dirt marks, real spirals, and front-layer dust particles
- Add BlobbiVariant type to body effects system for coordinate scaling
- Separate dirt/stink positions for baby (100x100) vs adult (200x200) viewBox
- Pass variant through applyBodyEffects and applyVisualRecipe pipeline
- Replace egg sick curved paths with real Archimedean spirals using createEggSpiralPath()
- Add generateDustParticles() with front+back layer particles for stronger dirty read
- Increase dust particle opacity and use darker colors for visibility
- Add front-layer dirty particles to egg statusEffects
2026-03-30 14:14:32 -03:00
filemon 18cf251c7e feat(blobbi): add egg status effects and improve adult dirt placement
## Egg Form Visual Effects

### 1. Dirty State (new)
- Sweat droplet near upper-left of egg (blue gradient, slides down animation)
- Dust particles underneath the egg (gentle float-up animation)
- Triggered when recipe has dirtMarks or stinkClouds bodyEffects

### 2. Health/Sick State (new)
- Floating purple dizzy spirals around the egg (3 spirals, rotate animation)
- Replaces adult dizzy eyes since eggs don't have faces
- Triggered when recipe has dizzySpirals in eyes

### 3. Happy State (new)
- Golden sparkle stars around the egg (3 sparkles, twinkle animation)
- Simple 4-point or 8-point star shapes
- Triggered when reaction='happy' and no tears

### Implementation
- Added EggStatusEffects interface: { dirty, sick, happy }
- Props flow: BlobbiStageVisual → BlobbiEggVisual → EggGraphic
- Status effects derived from recipe in BlobbiStageVisual
- All animations respect prefers-reduced-motion

## Adult Form Dirt Placement

### Problem
Dirt marks were appearing outside the Blobbi body silhouette.

### Solution
- Repositioned dirt marks to be centered within body area
- X range: 42-56 (was 35-55) - more centered
- Y range: 55-78 (was 72-80) - better vertical spread
- Added positions for count=4-5 for severity escalation
- Reduced stroke width (1.3 vs 1.5) and opacity (0.55 vs 0.6) for subtlety
- Stink clouds also recentered (x: 44-56 vs 38-62)

New files/exports:
- EggStatusEffects type exported from @/blobbi/egg
- 4 new CSS animations: egg-sweat-drop, egg-dust-particle, egg-spiral, egg-sparkle
2026-03-30 13:46:31 -03:00
filemon 5de5488b24 fix(blobbi): critical health mouth overrides sleepy mouth priority
When health is critical, the dizzy round mouth now wins over sleepy mouth
regardless of energy being low. This ensures severe states read as
'urgent/sick/disoriented' rather than just 'tired'.

## New mouth precedence rule
Critical health bypasses the normal MOUTH_PRIORITY list entirely.
The check happens before pickPart() for mouth resolution.

## Scenarios where critical-health mouth now wins
- health critical + energy low → dizzy mouth (was: sleepy mouth)
- health critical + energy low + hunger low → dizzy mouth
- health critical + everything low → dizzy mouth
- any scenario with health=critical → dizzy mouth guaranteed

## Scenarios where sleepy mouth still wins
- energy low + health normal/warning/high → sleepy mouth
- energy low + any other stats (no critical health) → sleepy mouth
- ordinary tiredness without severe illness → sleepy mouth

The exception is minimal: one conditional check before normal priority
resolution, documented in both MOUTH_PRIORITY and inline comments.
2026-03-30 13:36:33 -03:00
filemon 83887b0516 docs(blobbi): cleanup pass for expression system consistency
## A) Fixed outdated/contradictory comments
- ENERGY_PARTS: Clarified that lower cycleDuration = heavier eyelids (not 'slower')
- MOUTH_PRIORITY: Updated doc to reflect hunger's severity progression (round→droopy)
- resolveStatusRecipe(): Updated example compositions to match current behavior
- recipe.ts module doc: Clarified two pathways (presets vs status-driven)

## B) Aligned EMOTION_RECIPES presets with status-driven behavior
- hungry preset: Updated to match 'high' severity (mouth 3.5x4.5, brows -14°)
- dirty preset: Updated to match 'high' severity (grimace 0.8/0.2, brows +10°)
- Added documentation explaining presets align with high/critical severity

## C) Drool semantics decision: kept hunger-driven
- Drool remains semantically tied to hunger (salivating for food)
- No other stat has natural reason to produce drool
- Architecture already supports it; no changes needed
- Added clarifying comment documenting this decision

## D) Validated multi-stat combinations
All tested combinations produce natural pet-like expressions:
- Single stats: Each has distinct, readable expression
- Multi-stats: Priority rules produce sensible compositions
- Extras additive: Multiple stats can contribute drool + tears
- Body effects: Dirt/stink shows regardless of facial expression

No priority changes needed — current rules work well.
2026-03-30 13:32:03 -03:00
Alex Gleason ec24c4cfae Add zap option to profile 3-dots menu
Move zap functionality into the ProfileMoreMenu so users with a
lightning address can still be zapped. The menu row opens the ZapDialog
after the more menu closes via a hidden trigger ref.
2026-03-30 11:25:03 -05:00
filemon 002461e7cb refactor(blobbi): refine expression quality, severity behavior, and drool positioning
## Hunger Progression
- warning: hopeful/asking (small round 'ooh' mouth, mild pleading brows)
- high: needy (bigger round mouth, more worried brows)
- critical: weak/desperate (droopy pleading mouth, very worried brows)

Hunger now feels like genuine plea progression from 'ooh, food?' to 'please...'
to 'I'm so hungry...' rather than same expression at all levels.

## Health Eye Priority
- Only CRITICAL health claims eyes (dizzy spirals)
- Warning/high health no longer override sadness/hunger eyes
- This lets sad/hungry eyes show through when health is merely warning/high

## Severity Escalation (all stats)
Each stat now has documented severity escalation:
- energy: sleepy → heavier sleepy → very drowsy (slower blink cycles)
- hunger: asking → needy → desperate (mouth shape progression)
- happiness: down → sad → crying (eye wetness + tears progression)
- hygiene: uncomfortable → gross → very gross (grimace + dirt escalation)
- health: weak → sick → dizzy (only critical gets dizzy spirals)

## Drool Positioning Fix
- Added computeDroolAnchor() to calculate drool position based on final mouth shape
- Added generateDroolAtAnchor() to render drool at computed anchor
- Drool now correctly attaches to roundMouth, droopyMouth, sadMouth edges
- Previously drool used original mouth position, looked detached with some shapes

## Priority Order (unchanged)
Eyes: health(critical) > energy > happiness > hunger > hygiene
Mouth: energy > health > happiness > hunger > hygiene
Eyebrows: health > hunger > happiness > hygiene > energy
2026-03-30 13:21:47 -03:00
Alex Gleason d12e75ae5c Replace zap button with emoji reaction button on user profiles
Add ProfileReactionButton component that opens an emoji picker to send
kind 7 reactions to a user's profile with a, e, and p tags. Update
notifications to display 'reacted to your profile' for profile reactions
and skip rendering the referenced card for kind 0 events.
2026-03-30 11:06:32 -05:00
filemon a480379fa5 refactor(blobbi): refine emotion presets and part contributions for natural pet-like expressions
- Update EMOTION_RECIPES with better documentation and design principles
- Add design comments explaining each preset's purpose and feeling
- Refine hungry preset: pleading/hopeful (not sad) with round anticipating mouth
- Refine dirty preset: uncomfortable/irritated grimace with furrowed brows
- Refine dizzy preset: add distressed raised brows for urgency
- Update ENERGY_PARTS: emphasize relaxed drowsiness, no forced brows
- Update HEALTH_PARTS: weak/unwell feeling distinct from sadness
- Update HUNGER_PARTS: pleading expression with open 'ooh food?' mouth
- Update HYGIENE_PARTS: irritated grimace, slightly furrowed brows
- Update HAPPINESS_PARTS: genuine emotional sadness with progressive tears

Each stat now has a distinct visual 'personality' that creates empathy:
- Hunger evokes nurturing (hopeful, pleading)
- Energy evokes tiredness (drowsy, fading)
- Health evokes concern (weak, unwell)
- Hygiene evokes discomfort (irritated, 'I feel gross')
- Happiness evokes emotional connection (genuine sadness)
2026-03-30 12:42:47 -03:00
filemon c37d0d15a6 feat(blobbi): part-priority status reaction system for natural expressions
Replace the old 'single winning preset' approach in resolveStatusRecipe()
with a part-priority composition system. Each low stat now contributes
independently to eyes, mouth, eyebrows, extras, and bodyEffects.

Architecture:
- Each stat has a PartContributionResolver that returns what it contributes
  to each facial/body part at each severity level (warning/high/critical).
- Exclusive parts (eyes, mouth, eyebrows) use per-part priority lists to
  decide which stat wins that slot when multiple stats are low.
- Additive parts (extras, bodyEffects) merge contributions from all low
  stats simultaneously.

Part priority rules:
- Eyes: health(critical/dizzy) > energy(sleepy) > happiness(watery) > hunger
- Mouth: energy(sleepy) > health(sad/round) > happiness(sad) > hunger(droopy)
- Eyebrows: health > hunger(worried) > happiness(lowered) > hygiene(flat)
- Extras: additive — drool+food(hunger), tears(happiness), all coexist
- BodyEffects: additive — dirt+stink(hygiene), anger-rise, all coexist

Severity escalation examples:
- Happiness tears only appear at high/critical, not warning
- Health switches from sad face to dizzy spirals at critical
- Hunger droopiness and dirt mark counts scale with severity
- Happiness eye water fill only at critical (full crying)

Combined stat examples:
- hunger + hygiene: hungry eyes, droopy mouth, worried brows, drool +
  food icon, dirt + stink clouds
- energy + hunger: sleepy eyes, sleepy mouth, hungry eyebrows, drool +
  food icon
- health(critical) + energy: dizzy eyes (beats sleepy), sleepy mouth,
  health eyebrows
- all stats low: prioritized eyes/mouth/brows, additive drool + tears

Also adds 'sick' and 'dirty' entries to LABEL_CYCLE_DURATIONS in the
hook to match the new stat-based label format.
2026-03-30 12:33:19 -03:00
filemon 79ccfd661a refactor(blobbi): final recipe-first consistency pass
- Rename emotionName → recipeLabel in applyVisualRecipe() signature and
  update SVG class names from blobbi-emotion to blobbi-recipe, since the
  parameter carries a recipe label (e.g. 'hungry-sleepy'), not strictly
  an emotion name.

- Guard against bodyEffects double-application in BlobbiAdultVisual and
  BlobbiBabyVisual: skip the manual applyBodyEffects() call when a
  recipeProp is provided, since applyVisualRecipe() already applies
  recipe.bodyEffects internally. The manual bodyEffects prop remains
  available for non-recipe use cases only.

- Update module-level docs in recipe.ts, status-reactions.ts, and
  useStatusReaction.ts to consistently describe the recipe-first
  architecture without leftover emotion-layer terminology.

- Remove unused BodyEffectConfig import from recipe.ts (pre-existing
  eslint error).
2026-03-30 12:14:40 -03:00
Alex Gleason 67e8c23020 release: v2.2.3 2026-03-30 09:59:34 -05:00
Alex Gleason 94f0c8308d Show all sidebar items to logged-out users and update sidebar order
Stop filtering requiresAuth items from navigation. Pages already render
their own LoginArea when the user is not logged in, so hiding the items
from the sidebar, mobile drawer, and search prevented feature discovery
without providing any benefit.

Also update the default sidebar order: remove bookmarks and profile,
add letters.
2026-03-30 09:56:02 -05:00
filemon c77b68eed2 fix(blobbi): eliminate body effects duplication, fix stat recovery, improve merged label timing
- Remove bodyEffects from StatusRecipeResult and useStatusReaction output.
  Body effects are folded into recipe.bodyEffects by resolveStatusRecipe()
  and applied once by applyVisualRecipe(). No separate channel needed.

- Fix stat recovery logic: re-resolve via resolveStatusRecipe() on every
  stat change instead of forcing neutral when previous triggering stat
  recovers. If energy recovers but hunger is still low, the hook now
  correctly transitions to the hungry recipe instead of neutral.

- Fix getRecipeCycleDuration() for merged labels (e.g. 'boring-sleepy'):
  compute Math.max() of all matching durations instead of returning the
  first match.

- Update all consumers (BlobbiPage, BlobbiCompanionLayer) to stop
  destructuring/passing bodyEffects from status reaction output.

- Update doc comments across visual components to clarify that the
  bodyEffects prop is for manual/external use only, not for status
  reaction data.
2026-03-30 11:52:00 -03:00
Chad Curtis 80820ae9c4 Expand compose textarea smoothly as you type 2026-03-30 07:36:16 -05:00
Chad Curtis 5288b7a718 Letters: overflow menu, reply button, grid layout, and UX polish
- EnvelopeCard: add ... overflow menu trigger using NoteMoreMenu (no duplicated logic)
- NoteMoreMenu: detect NIP-44 ciphertext by content shape, show 'Encrypted content'
- LetterDetailSheet: reply button (InkPenIcon, primary colors); gift above card; letter centered in viewport; click outside closes
- LettersPage: reply pre-fills sender npub; default stationery falls back to parchment when no custom theme; 2-col mobile / 4-col desktop grid
- NotificationsPage: reply button next to 'View all letters' in letter notifications
- ComposeLetterSheet: auto-focus textarea on open; fix To field overflow on narrow screens
- InkPenIcon: new custom icon replacing PenLine on FAB and reply buttons
2026-03-30 06:21:50 -05:00
Chad Curtis 4643830512 Sync letter improvements from lief
- Make LetterContent.body optional; a letter requires a non-empty body
  or at least one sticker
- Replace colors:[]/flatMode with event-stripping: flat color moment =
  event field stripped from Stationery, removing the colors? field
- Remove edgeScale: stickers render at their stored scale value with no
  edge-proximity size reduction, matching lief and the NIP
- Fix sticker shrinking near card edges: add max-width:none to override
  Tailwind preflight max-width:100% on img/svg elements
2026-03-30 04:30:00 -05:00
Chad Curtis ef04de67c0 Improve notification rendering for badges and letters
- Add hideKindHeader prop to NoteCard, used by ReferencedNoteCard to
  suppress redundant action headers in repost/reaction/zap notifications
- Redesign badge award notification: show full BadgeContent showcase card
  with prominent rounded-pill Accept Badge button below
- Redesign letter notification: larger centered envelope (minimal mode
  hides name/timestamp), click opens LetterDetailSheet inline, View All
  Letters button below
2026-03-30 00:13:01 -05:00
Chad Curtis 5847cceba6 Auto-shrink stickers near card edges and clip overflow at rounded boundary
Stickers now scale down proportionally as their center approaches any
edge of the letter card, preventing them from overflowing the rounded
corners. A 5% drag buffer keeps sticker centers away from the very
edge. The sticker overlay uses pointer-events-none so the textarea
remains clickable underneath.
2026-03-29 23:53:20 -05:00
Chad Curtis f62b86027c Remove unused eslint-disable directive in LayoutContext 2026-03-29 22:59:41 -05:00
Chad Curtis 8e5018d3b2 Fix 3 letter bugs: allow drawing-only sends, fix sticker drag bounds, preserve theme events
- Allow sending letters with only stickers/drawings (no body text required)
- Fix sticker drag positioning by using cardRef instead of page container
  ref, so percentage coordinates map correctly to the card boundaries
- Preserve stationery source events (color moments/themes) in preferences
  so they embed as gift attachments in sent letters
- Accept sticker-only letters during decryption validation
2026-03-29 22:41:03 -05:00
Chad Curtis 8df17f5ae7 Fix top bar arc flash by deferring layout cleanup past Suspense boundary 2026-03-29 22:23:23 -05:00
Alex Gleason dd31ce681f Add Blobbi to default sidebar order after Badges 2026-03-29 22:05:31 -05:00
filemon fd9a963b27 refactor(blobbi): complete recipe-first architecture, remove secondaryEmotion
Finish the migration from emotion-name composition to final visual
recipe resolution throughout the rendering pipeline.

Key changes:
- New emotion-types.ts: neutral type file for BlobbiEmotion/BlobbiVariant,
  breaking the import cycle between recipe.ts and emotions.ts
- status-reactions.ts: resolveStatusRecipe() now returns a fully resolved
  BlobbiVisualRecipe directly (merging sleepy+boring etc. internally)
- useStatusReaction: tracks resolved recipe state, outputs recipe+recipeLabel
  instead of emotion+secondaryEmotion
- Visual components (Adult, Baby, Stage): accept recipe+recipeLabel prop
  for recipe-first rendering; emotion prop kept as convenience for presets
- Companion components: pass recipe directly, no more secondaryEmotion
- BlobbiPage: passes resolved recipe from useStatusReaction to visuals
- emotions.ts: removed applyMergedEmotion() and mergeVisualRecipes re-export
- mergeVisualRecipes() stays in recipe.ts as an internal utility only used
  by status-reactions.ts for combining low-stat recipes

secondaryEmotion is fully eliminated from the codebase (0 occurrences).
The rendering path is now recipe-first end-to-end.
2026-03-29 23:37:27 -03:00
filemon 672d252492 refactor(blobbi): replace monolithic emotion system with part-based visual recipe architecture
Introduce BlobbiVisualRecipe as the central type for composing Blobbi
expressions from independent parts (eyes, mouth, eyebrows, bodyEffects,
extras). Named emotions are now presets that resolve into part-based
recipes via resolveVisualRecipe().

Key changes:
- New recipe.ts with BlobbiVisualRecipe types, EMOTION_RECIPES, and
  applyVisualRecipe() rendering pipeline
- emotions.ts becomes a thin public API delegating to recipe.ts
- Remove base/overlay emotion stacking model from status-reactions.ts
  and useStatusReaction.ts in favor of single emotion + secondaryEmotion
  for recipe-level merging
- Visual components (Adult, Baby, Stage) now resolve and merge recipes
  in a single pass instead of calling applyEmotion() twice
- Companion components updated to use secondaryEmotion prop
- All existing emotion presets preserved with identical visual output
- Backward-compatible: applyEmotion() API unchanged, legacy type aliases
  provided for EmotionConfig and EMOTION_CONFIGS
2026-03-29 23:16:25 -03:00
filemon bc4e00520e feat(blobbi): use stable idPrefix for body effect SVG element IDs
- applyEmotion now accepts optional instanceId parameter (5th arg)
- instanceId is passed through to applyBodyEffects as idPrefix
- BlobbiAdultVisual and BlobbiBabyVisual now pass blobbi.id as instanceId
- Anger-rise clip paths and gradients now use blobbi.id for stable IDs
  (e.g., blobbi-anger-clip-abc123 instead of random suffix)
- Random fallback still exists when instanceId is not provided
- Same Blobbi instance now produces deterministic SVG output
2026-03-29 22:03:00 -03:00
filemon d777d1bc98 refactor(blobbi): make bodyEffects/apply.ts the single entry point for body effects
- emotions.ts now delegates all body effects to applyBodyEffects()
- Removed direct imports of detectBodyPath, generateAngerRiseEffect,
  generateDirtMarks, generateStinkClouds from emotions.ts
- emotions.ts now only imports applyBodyEffects and BodyEffectsSpec
- Added unique ID generation for anger-rise clip paths and gradients
  (prevents collisions when multiple Blobbis render on same page)
- Body effects are applied after face overlays via single applyBodyEffects call
- Anger-rise overlay is still inserted right after body path for z-ordering
2026-03-29 21:53:30 -03:00
filemon 4cd97124da refactor(eyebrows): centralize class names and form offsets
- Add EYEBROW_CLASSES constant with all CSS class names
- Add FORM_EYEBROW_OFFSETS map for owli/froggi adjustments
- Rename keyframe from 'eyebrow-bounce' to 'blobbi-eyebrow-bounce'
- Export both constants from index.ts
- No behavior change, same public API
2026-03-29 21:08:42 -03:00
Alex Gleason 74345fdb2f Fix feed gaps when replies are disabled by over-fetching from relay
When followsFeedShowReplies is false, the relay limit was PAGE_SIZE (15)
but client-side reply filtering could discard most events, leaving only
a few visible posts per page with large time gaps between them.

Apply the same over-fetch pattern already used by useProfileFeed:
- Request PAGE_SIZE * 3 events when reply filtering is active
- Use rawCount (pre-filter) for pagination termination so pages where
  all items are replies don't prematurely stop pagination
2026-03-29 18:51:20 -05:00
filemon 7e7abdee3d simplify sleepy mouth to direct replacement
- Sleepy mouth is now clearly documented as a canonical standalone shape
- Direct replacement: no morph, transition, or interpolation between mouth states
- Keeps MouthAnchor architecture for stable positioning
- Updated docs across mouth/, types, and emotions.ts to be consistent
- Removed any wording suggesting transitions or morphing
2026-03-29 20:49:42 -03:00
filemon 9ed2127494 Introduce MouthAnchor for stable sleepy mouth positioning
applySleepyMouth no longer calls detectMouthPosition internally.
Instead, the orchestrator derives a MouthAnchor from the original
neutral SVG during the detection phase and passes it through.

This makes sleepy mouth placement reliable regardless of what base
emotion mouth (round ellipse, frown path, droopy, etc.) was applied
before the sleepy overlay runs.

mouth/types.ts:
- Added MouthAnchor interface ({ cx, cy })

mouth/detection.ts:
- Added mouthAnchorFromDetection(detection): derives { cx, cy } from
  a MouthDetectionResult (center of startX..endX, controlY)

mouth/generators.ts:
- applySleepyMouth now takes (svgText, anchor: MouthAnchor) instead of
  detecting internally. No more dependency on detectMouthPosition from
  within the sleepy mouth path. generateSleepyMouth is unchanged.

emotions.ts:
- Detection phase now computes mouthAnchor alongside mouth and eyes
- applySleepyAnimation receives the anchor and passes it through
- The anchor is always from the original unmodified SVG
2026-03-29 20:28:30 -03:00
filemon 30608ae8ed Merge branch 'main' into implement-reactions-clean 2026-03-29 20:24:04 -03:00
filemon ae43014cf2 Replace sleepy mouth morph with canonical breathing mouth shape
Remove the old sleepy mouth behavior that morphed the current mouth path
(smile → U-shape → smile via SMIL path animation). Replace with a
dedicated sleepy mouth: a small filled ellipse with a subtle breathing
animation (gentle expand/contract cycle, 3s period).

What changed:

mouth/generators.ts:
- Added generateSleepyMouth(centerX, centerY): produces a canonical
  small round ellipse (rx=2.8, ry=3.2) with SMIL breathing animation
- Added applySleepyMouth(svgText): detects current mouth position,
  generates the sleepy mouth, replaces whatever mouth is present
- Removed applySleepyMouthAnimation (the old morph-based approach)

mouth/detection.ts:
- Added replaceCurrentMouth(svgText, newMouthSvg): finds any element
  with blobbi-mouth class (path or ellipse, self-closing or with
  children) and replaces it. Falls back to Q-curve path matching.
  This handles all mouth types: base smile, sad frown, round mouth,
  droopy mouth, and previously-animated mouths.

mouth/types.ts:
- Removed SleepyMouthAnimationConfig (no longer needed)

emotions.ts:
- applySleepyAnimation no longer takes a mouth parameter
- Calls applySleepyMouth(svgText) instead of the old morph function
- Sleepy eye behavior (clip-path SMIL, closed-eye lines, wake-glance
  CSS, Zzz text) is completely unchanged

The sleepy mouth is now a proper canonical mouth shape in the mouth/
module, positioned at the detected mouth center, independent of
whatever base emotion mouth was applied before it.
2026-03-29 20:14:09 -03:00
filemon ea8d3dd0f3 Extract real implementations into mouth/, eyebrows/, bodyEffects/ modules
Complete the modularization of the visual emotion system. Each subsystem
now owns its implementation rather than re-exporting from emotions.ts.

mouth/ (554 lines):
- detection.ts: marker-based + regex fallback mouth detection, replacement
- generators.ts: round, sad, droopy, big smile, small smile, drool, food
  icon, sleepy mouth morph animation
- types.ts: MouthPosition, MouthDetectionResult, shape configs
- index.ts: barrel exports

eyebrows/ (194 lines):
- generators.ts: eyebrow generation with per-eye overrides,
  variant/form offsets, animated bounce styles
- types.ts: EyebrowConfig, AnimatedEyebrowsConfig
- index.ts: barrel exports

bodyEffects/ (392 lines):
- generators.ts: body path detection, dirt marks, stink clouds,
  anger-rise effect (full implementation moved from emotions.ts)
- apply.ts: applyBodyEffects() composable applicator
- types.ts: BodyEffectConfig, BodyPathInfo, BodyEffectsSpec
- index.ts: barrel exports

eyes/ (unchanged):
- Already correctly modular. Confirmed no stranded eye code remains
  in emotions.ts — the legacy applySadEyeWaterFill/applySadEyeHighlights
  functions were dead code (superseded by eyes/effects.ts) and removed.

emotions.ts (2135 → 628 lines):
- Now a pure orchestrator: recipe definitions + composition logic
- Imports all implementations from subsystem modules
- Contains only: BlobbiEmotion type, EMOTION_CONFIGS recipes,
  applyEmotion() orchestrator, tear generation (cross-cutting overlay),
  sleepy animation coordination (cross-cutting, touches eyes + mouth),
  and deprecated re-exports for backward compatibility
- No more detection internals, SVG geometry generators, or effect
  implementations
2026-03-29 19:55:27 -03:00
Alex Gleason 02231ea1f9 Fix avatar shape flash by computing mask URL synchronously
The Avatar component was initializing maskUrl as '' and loading it in a
useEffect. Since hasCustomShape was true immediately, rounded-full was
removed on the first render, but the mask wasn't applied until after the
effect fired — causing a visible square flash for one frame.

getAvatarMaskUrl is already synchronous (renders emoji to canvas, caches
the data-URL), so compute it inline during render instead of deferring
to an effect. The mask is now applied on the very first paint.
2026-03-29 17:47:57 -05:00
Alex Gleason effc704613 Fix 'Cannot update component while rendering' warning in useLayoutOptions
useLayoutOptions was calling store.setOptions() synchronously during
render, which triggered useSyncExternalStore listeners in MobileBottomNav
(and MainLayout) while Index was still rendering.

Move the store update into useLayoutEffect, which fires synchronously
after commit but before browser paint — same visual result without
violating React's setState-during-render rule.
2026-03-29 17:43:55 -05:00
Alex Gleason 51fb1fd1cb Give comments (kind 1111) and generic reposts (kind 16) independent feed toggles
Previously comments shared feedKey 'feedIncludePosts' with kind 1, and
generic reposts shared 'feedIncludeReposts' with kind 6. This made it
impossible to toggle them independently in settings.

Add feedIncludeComments and feedIncludeGenericReposts to FeedSettings
and wire them to their respective EXTRA_KINDS entries.
2026-03-29 17:33:56 -05:00
Alex Gleason 1988e1b849 Fix duplicate React keys in content settings
Multiple ExtraKindDef entries share the same feedKey (e.g. posts/comments
both use feedIncludePosts) and multiple subKinds share the same showKey
(e.g. both video sub-kinds use showVideos). Using these as React keys
caused 'duplicate key' warnings.

Use def.id (always unique) for ContentTypeRow keys and sub.feedKey
(unique per sub-kind) for SubKindRow keys.
2026-03-29 17:29:45 -05:00
Alex Gleason 1b782f65d1 Display QR code in terminal for NIP-46 auth script 2026-03-29 16:58:22 -05:00
Alex Gleason 6c4eddece7 Add NIP-46 client-initiated auth script for Zapstore CI signing 2026-03-29 16:47:36 -05:00
filemon cf0524a211 Extract composable visual modules and decouple dirty from face emotions
Foundation for migrating the monolithic emotion system toward a composable
architecture where each visual area (eyes, mouth, eyebrows, body effects)
is handled independently.

New modules created:
- bodyEffects/ — types, generators (dirt marks, stink clouds, anger rise),
  and applyBodyEffects() for applying body decorators independently of face
- mouth/ — types and re-exports of existing mouth detection/generation
- eyebrows/ — types and re-exports of existing eyebrow generation

Dirty emotion refactored:
- Removed face modifications (droopyMouth, eyebrows) from EMOTION_CONFIGS.dirty
- dirty is now a body-only decorator that adds dirt marks + stink clouds
  without touching eyes, mouth, or eyebrows
- Hygiene stat now maps to 'boring' as the face emotion (same as happiness)
- Body effects (dirty) are resolved independently in resolveStatusEmotions()
  and flow as a separate bodyEffects field through the entire pipeline:
  resolveStatusEmotions → useStatusReaction → BlobbiStageVisual →
  BlobbiAdultVisual/BlobbiBabyVisual → applyBodyEffects()
- Any face + dirty is now possible: boring+dirty, sleepy+dirty, dizzy+dirty

The existing emotion system (applyEmotion) still works unchanged for all
other emotions. The eyes/ module already existed. This is an incremental
step — no full migration yet.
2026-03-29 17:57:45 -03:00
Alex Gleason a796f279a5 release: v2.2.2 2026-03-29 15:48:26 -05:00
Alex Gleason efc491bad4 Add release notes to Zapstore publishing 2026-03-29 15:45:52 -05:00
filemon 8d04bbbdbe Complete baseEmotion + overlayEmotion migration across the visual pipeline
Finish the two-layer emotion architecture so resolveStatusEmotions() is
the single source of truth and both the main BlobbiPage and the floating
companion use the same flow.

useStatusReaction:
- Now returns baseEmotion, overlayEmotion, triggeringBaseStat,
  triggeringOverlayStat, isStatusReactionActive, currentSeverity,
  isOverrideActive (replaces the old single currentEmotion).
- Internally calls resolveStatusEmotions() on every check cycle and
  tracks base and overlay transitions independently with animation
  safety per layer.
- Action overrides replace the overlay; the base persists underneath.

status-reactions.ts:
- Remove combineEmotions() (no longer needed).
- Deprecate resolveStatusReaction() with a JSDoc notice.
- resolveStatusEmotions() is now the primary API.

BlobbiStageVisual:
- Accepts a new baseEmotion prop and forwards it to BlobbiBabyVisual
  and BlobbiAdultVisual.

BlobbiPage (main consumer):
- Destructures baseEmotion + overlayEmotion from the hook and passes
  both through to BlobbiStageVisual.

Companion system:
- CompanionData now carries full BlobbiStats and state.
- BlobbiCompanionLayer runs its own useStatusReaction to drive the
  companion's emotions from stats, including item-use action overrides.
- BlobbiCompanion and BlobbiCompanionVisual accept baseEmotion + emotion
  props and forward them to the underlying visual components.
2026-03-29 17:27:17 -03:00
Alex Gleason f6c08f8afa Add badge list recovery dialog for kind 10008 events
Allow users to browse and restore previous versions of their accepted
badges (profile badges) from relay history, matching the existing
mute list recovery pattern in /settings/content.
2026-03-29 15:22:51 -05:00
Alex Gleason 3197c53fcc Fix PageSkeleton to match actual page layout structure
The lazy-loading skeleton was missing center column borders
(sidebar:border-l/r) and the right sidebar widget backgrounds
(bg-background/85 rounded-xl). Updated to mirror the real Outlet
wrapper classes and RightSidebar widget card styling with three
distinct skeleton sections (Trends, Hot Posts, New Accounts).
2026-03-29 15:14:09 -05:00
Alex Gleason 7b793149b3 Reserve grid cell for +N overflow indicator in badge lists
When a badge list overflows PREVIEW_LIMIT, show one fewer badge to
make room for the +N button on the same row instead of widowing it.
The loading skeleton now also includes a placeholder for the overflow
cell when applicable.
2026-03-29 15:08:48 -05:00
Alex Gleason 24c938728a Replace spinner with skeleton grid for badge list loading state
Shows placeholder skeletons matching the badge grid layout (48px
rounded squares + name bars) instead of a centered spinner while
badge definitions are being fetched.
2026-03-29 15:06:33 -05:00
Alex Gleason 1c358a3c79 Add compact badge row preview for embedded profile badges events
Kind 10008/30008 profile badges events now render a compact card with
author info, a row of up to 6 badge thumbnails, and a badge count
when embedded in quotes or reply context. Works in both EmbeddedNote
(nevent references) and EmbeddedNaddr (naddr references).
2026-03-29 15:04:58 -05:00
Alex Gleason 3bbed8875c Remove inline compose box from profile badges detail view
The badge collection detail page (kind 10008/30008) no longer shows
a ComposeBox between the badge grid and the comments section.
2026-03-29 15:02:12 -05:00
filemon a3e6ff34db Add boring and dirty emotions to dev emotion tester panel
- Added boring emotion (😑) - low-energy, unamused expression
- Added dirty emotion (💩) - hygiene-specific with dirt/stink visuals
- Maintains existing emotion order with new emotions near the top
2026-03-29 17:01:48 -03:00
Alex Gleason 0f7fc673eb Make +N overflow indicator clickable to expand full badge grid
Clicking the '+2' (or similar) button at the end of a truncated badge
grid now reveals all badges instead of being a static indicator.
2026-03-29 15:01:17 -05:00
Alex Gleason 646c95a86f Show kind action header on threaded ancestor NoteCards
The KIND_HEADER_MAP action header (e.g. 'updated their badges',
'created a badge', 'shared a photo') was only rendered in the normal
NoteCard layout. Now it also appears in the threaded layout, so parent
events shown as ancestors in reply threads display their kind context.
2026-03-29 14:56:55 -05:00
Alex Gleason 87a8974c8c Show parent event as threaded NoteCard for kind 1111 comments
When viewing a NIP-22 comment (kind 1111) that references its parent
via an 'a' tag (addr coordinates) rather than an 'e' tag (event ID),
the parent event is now rendered as a full threaded NoteCard with a
connector line — matching how kind 1 reply threads display ancestors.

Previously these showed a compact AddressableEventPreview banner.
Now the parent badges list (or any other addr-referenced event) renders
inline in the thread, giving proper visual context for the comment.
2026-03-29 14:55:48 -05:00
Alex Gleason 9b5df28b93 Fix naddr routing for replaceable events (kind 10000-19999)
Replaceable events have no d-tag, so useAddrEvent must omit the #d
filter for kinds in the 10000-19999 range. Without this, querying
for a kind 10008 profile badges event via naddr would include
'#d': [''] in the filter, which fails to match events without a d-tag.
2026-03-29 14:47:52 -05:00
Alex Gleason e876e290da Encode replaceable events (kind 10000-19999) as naddr URLs
Replaceable events should use naddr encoding (kind + pubkey + empty
identifier) rather than nevent (event ID), since they are identified by
their coordinates. This fixes kind 10008 profile badge events linking
to /nevent1... instead of /naddr1... from the feed.
2026-03-29 14:45:45 -05:00
Alex Gleason f26f033b14 Support viewing kind 10008/30008 profile badges via nevent URLs
PostDetailPage (used for nevent1 identifiers) was missing the kind
10008/30008 branch, so profile badge events fell through to the generic
PostDetailContent. AddrPostDetailPage already had this handling via
ProfileBadgesDetailView — now PostDetailPage shares the same code path
for both badge definitions (30009) and profile badges (10008/30008).
2026-03-29 14:41:24 -05:00
Alex Gleason 565f323179 Add mouse-only 3D tilt to BadgeThumbnail with visible effect
Build the tilt directly into BadgeThumbnail instead of a separate
wrapper. Use aggressive parameters (35deg max tilt, 1.15x scale,
perspective = size*3) so the effect is clearly visible on small
28-48px thumbnails. Add a perspective parameter to useCardTilt.

Remove old group-hover:scale-110 from all badge grid call sites
(BadgeShowcaseGrid, ProfileBadgesContent, ProfilePage,
ProfileHoverCard) since the tilt+scale is now built into the
thumbnail itself.
2026-03-29 14:37:46 -05:00
filemon 82b2aeb294 Refactor Blobbi visual state system: separate base face from overlay animations
- Add 'boring' persistent face: low-energy, unamused expression (replaces sad as generic fallback)
  - Droopy mouth with shallow curve, flat eyebrows
  - Used for non-critical bad stats (health, happiness)

- Add 'dirty' persistent state: hygiene-specific visuals
  - Includes dirt marks on lower body (3 curved scratch-like lines)
  - Animated stink clouds floating upward
  - Uses boring face as base + hygiene effects

- Refactor 'sleepy' to be an overlay animation
  - KEY FIX: sleepy now animates the CURRENT mouth state instead of resetting to default smile
  - When Blobbi is unwell (boring/dirty/dizzy face), sleepy animation preserves that base face
  - Implementation: applySleepyMouthAnimation finds existing mouth path and animates from there
  - Example: boring face + sleepy = boring expression with sleepy animation on top

- Update status-reactions.ts emotion mapping
  - health: boring (not feeling good) → dizzy (critical)
  - hygiene: dirty (poor hygiene visuals)
  - happiness: boring (low energy, unamused)
  - energy: sleepy (now an overlay, not base-replacing)

- Add base + overlay emotion architecture to visual components
  - BlobbiAdultVisual and BlobbiBabyVisual now accept optional baseEmotion prop
  - Emotions applied sequentially: base first, then overlay
  - Preserves existing behavior when only one emotion provided

- Add resolveStatusEmotions() utility
  - Separates base emotions from overlay emotions
  - Returns StatusEmotionResult with baseEmotion and overlayEmotion
  - Enables proper multi-stat handling (e.g., low health + low energy = boring face with sleepy overlay)

Architecture notes:
- Base emotions (boring, dirty, dizzy, sad, happy, etc.): replace face completely
- Overlay emotions (sleepy): animate on top without replacing base
- Critical fix: Blobbi no longer visually resets to happy during sleepy cycle when in bad state
2026-03-29 16:36:16 -03:00
Alex Gleason 6b59658f00 Add mouse-only 3D tilt effect to badge images in feed cards
Reuse useCardTilt for the badge image in BadgeContent feed cards, but
only respond to mouse/pen pointer events. Touch events are explicitly
ignored and touch-action is set back to auto so tapping through to the
badge detail view and normal scrolling are unaffected. Includes the
specular glare overlay masked to the badge image shape.
2026-03-29 14:28:33 -05:00
Alex Gleason 2e1e4416b3 Add touch support to badge 3D tilt effect
Update useCardTilt to handle touch inputs via PointerEvent. Touch
interactions use a press-and-drag gesture: the tilt follows the finger
while down, then holds for 600ms after release before smoothly
resetting. touch-action: none prevents the browser from intercepting
the gesture for scrolling. Mouse behavior is unchanged.

Update BadgeHero glare overlay to match: glare follows touch position
during the drag and fades after the same linger delay on release.
2026-03-29 14:26:02 -05:00
Alex Gleason e92a2c571c Remove 'Badge' pill from badge detail issuer row
Remove the secondary Badge UI element showing '<Award icon> Badge' next to
the issuer name on the badge detail page. The context is already clear from
the page layout and hero image.
2026-03-29 14:20:52 -05:00
Alex Gleason d3a418b5ee Remove redundant header from profile badges feed card
Remove the 'Name's Badges (N badges)' header line from ProfileBadgesContent
to reduce visual clutter. The badge grid and NoteCard's KIND_HEADER_MAP
already provide sufficient context.
2026-03-29 14:18:37 -05:00
Alex Gleason c9945107e9 Replace Photos and Videos with Badges in default sidebar order 2026-03-29 14:14:55 -05:00
Alex Gleason 4c70133ca9 Clarify EmbeddedNote vs NoteCard compact prop in AGENTS.md
Rename the checklist item from 'Inline embeds / quote posts' to
'Embedded note cards' with explicit file paths, explain that kinds
with tag-based media may need attachment indicator updates, and add
a note distinguishing EmbeddedNote components from the NoteCard
compact prop to prevent confusion.
2026-03-29 14:11:51 -05:00
Alex Gleason 0df942cb9d Display kind 20 photo events in detail view and add Photo indicator
- Add isPhoto detection and PhotoDetailContent in PostDetailPage so kind 20
  events render their image gallery when viewed directly via nevent links
- Add parsePhotoUrls helper and ImageGallery import to PostDetailPage
- Add 'Photo' shell title for kind 20 loading state
- Add KIND_HEADER_MAP entry for kind 20 ('shared a photo') in NoteCard
- Add Photo attachment indicator in EmbeddedNote for kind 20 events in
  quote posts and reply context
2026-03-29 14:09:39 -05:00
Alex Gleason 37a63f068b Make version tag and date clickable links, remove external link icon 2026-03-29 13:48:07 -05:00
Alex Gleason cc8638e8b2 Show build date instead of commit hash in pre-release banner, compare to current commit 2026-03-29 13:46:26 -05:00
filemon fd20081ce8 fix: prevent reaction animations from being cut off by status recomputation
Refactored useStatusReaction hook to be more stateful and animation-aware:

- Track currently active reaction to avoid restarting same reaction
- Distinguish between persistent (sleepy, sad, dizzy, hungry) and one-shot reactions
- Persistent reactions loop continuously while condition remains active
- Only replace reactions when: type changes, higher priority interrupts, or one-shot completes
- Remove stats from useEffect dependencies to prevent reset on every recomputation
- Add animation cycle duration awareness to avoid mid-animation interruptions
- Use refs for stats/timing to maintain stable callback references

This ensures:
- Sleepy animation completes full cycle including slow eye opening
- Crying/sad reactions don't reset before tear cycle completes
- Dizzy animation doesn't keep resetting its visual motion
- Eyebrow/face reactions don't flicker from repeated reapplication
2026-03-29 15:17:33 -03:00
Alex Gleason cf9d409166 Migrate profile badges from kind 30008 to kind 10008
Profile badges should be a replaceable event (kind 10008), not an
addressable event (kind 30008) with a fixed d-tag. This follows the
same deprecation pattern used by NIP-51 lists.

All writes now publish kind 10008. All reads query both 10008 and
legacy 30008, picking whichever is newest, for backwards compatibility
during the transition period.
2026-03-29 13:10:22 -05:00
Chad Curtis 7d8ac49fe2 Document fetchFreshEvent pattern in AGENTS.md 2026-03-29 10:08:44 -05:00
Chad Curtis dc3be6564b Fix stale-cache overwrites in replaceable event mutations
Extract shared fetchFreshEvent() utility that fetches the freshest
version of a replaceable/addressable event directly from relays before
every mutation. This prevents data loss when the TanStack Query cache
is stale (cross-device edits, rapid sequential operations).

Previously only useFollowActions and useMuteList had this safety
pattern. Now all list-type hooks use the same shared primitive:
useAcceptBadge, useRemoveBadge, useBookmarks, usePinnedNotes,
useInterests, and useUserLists.
2026-03-29 10:05:58 -05:00
Chad Curtis 337e27f2b5 Add multi-select badge awarding with already-sent indicators 2026-03-29 09:47:05 -05:00
Chad Curtis c400437662 Add kinds 10015, 10030, 10063 to NostrBatcher REPLACEABLE_KINDS
Interests, custom emojis, and Blossom server list queries now batch
with profile/follow/mute queries instead of firing separate REQs,
reducing ~3 REQs on feed load.
2026-03-29 08:51:41 -05:00
Chad Curtis e8941e8ef6 Fix search page 'N new posts' pill showing unfiltered count
The stream buffer count was reported raw without applying client-side
filters (search query, media type, replies, protocol, mute list, etc.),
so the pill would show e.g. '10 new posts' when only 3 matched the
active search criteria. Extract the filtering predicate into a shared
matchesFilters callback and derive the pill count from filtered buffer
contents instead of the raw streamBufferCount.
2026-03-29 08:00:29 -05:00
Chad Curtis 169823980f Add arc overhang spacer to Photos and Videos pages
Both pages have a SubHeaderBar but were missing the ARC_OVERHANG_PX
spacer div that prevents content from sitting behind the arc background.
Every other page with tabs already includes this spacer.
2026-03-29 07:58:26 -05:00
Chad Curtis aa7376b357 Add pull-to-refresh to all feed pages via usePageRefresh hook
Missing pull-to-refresh on Photos, Videos, Trends, Search, Bookmarks,
TagFeed (#t/#g), DomainFeed pages meant Android users had no way to
refresh content without navigating away.

- Create usePageRefresh hook that wraps queryClient.invalidateQueries
  with a referentially-stable callback (ref-based) for PullToRefresh
- Wrap scrollable content in PullToRefresh on all affected pages
- Fix Feed.tsx: HashtagFeedContent, GeotagFeedContent, and
  SavedFeedContent tabs now include PullToRefresh (were outside wrapper)
- Refactor Events, Books, Themes pages to use usePageRefresh for
  consistency and reduced boilerplate
2026-03-29 06:54:58 -05:00
Chad Curtis 6a0e88cbf1 Fix gap between arc and top nav on relay feed page mobile 2026-03-29 06:36:14 -05:00
Chad Curtis 01b7e1cea2 Stop click propagation on delete confirmation AlertDialog content
Clicks inside the portaled AlertDialog bubble through React's synthetic
event tree to the NoteCard article, triggering post detail navigation.
Adding stopPropagation on AlertDialogContent prevents any click inside
the delete confirmation from reaching the card handler.
2026-03-29 06:23:01 -05:00
Chad Curtis 910f43e0a5 Stop click propagation in NoteMoreMenu items to prevent click-through
MenuItem button clicks inside the Radix Dialog portal bubble through
React's synthetic event system to the parent article's handleCardClick,
causing navigation to post details when selecting menu actions like
delete. Adding stopPropagation prevents this.
2026-03-29 06:21:15 -05:00
Chad Curtis 6bf630bb40 Fix delete post dialog freezing feed on desktop
Move the delete confirmation AlertDialog out of NoteMoreMenuContent
(where it was nested inside the more-menu Dialog) and into the parent
NoteMoreMenu component. The nested Radix dialogs caused overlapping
overlays and focus traps that left the page uninteractable after
confirming deletion. Now follows the same close-then-open pattern used
by Report, Mention, AddToList, and EventJson dialogs.
2026-03-29 06:16:23 -05:00
Chad Curtis 5e71b3f44a Merge branch 'fix/emphasize-custom-emojis' into 'main'
Custom emoji improvements & reaction fixes

See merge request soapbox-pub/ditto!140
2026-03-29 11:08:38 +00:00
filemon 5ffab157d7 Unify eye system contract and fix animation conflicts
- Standardize data attributes: data-cx/cy → data-eye-cx/cy with legacy fallback
- Replace hardcoded eye selectors with EYE_CLASSES constants from eyes/types
- Remove unused side-specific clip rect class variants from EYE_CLASSES
- Fix sleepy animation: skip JS blink when SMIL animations present
- Fix companion reaction support: skip eye transforms when CSS animations active
- Update detection.ts to try new attribute format first, fall back to legacy
- Update useBlobbiEyes and useExternalEyeOffset to respect CSS animations
2026-03-29 05:16:51 -03:00
filemon c6e791d18f Implement reactions 2026-03-29 04:41:42 -03:00
Lemon a80b306248 Reset feed composer to collapsed state after posting 2026-03-28 23:11:47 -07:00
Lemon c8c294a8ad Match ComposeBox background opacity with header and subheader (bg-background/85) 2026-03-28 23:11:47 -07:00
Lemon 5a30376f2c Fix emoji shortcode autocomplete text and highlight colors
The shortcode label used text-muted-foreground making it hard to read.
Changed to inherit text-popover-foreground so it matches the theme's
normal text color. The selection/hover highlight now uses bg-secondary/60,
matching the subtle hover shade used by the NoteMoreMenu items.
2026-03-28 23:07:21 -07:00
Lemon 373219ecfa Reorder emoji picker: Recent, Custom, then standard categories
Move the Custom (NIP-30) emoji tab from last position to second, right
after Recent, so users can quickly access their custom emoji packs.
2026-03-28 23:07:21 -07:00
Lemon 1ef1400699 Track custom emoji usage so they appear in quick-react bar
Custom emojis were explicitly excluded from usage tracking, so they could
never accumulate enough count to appear in the quick-react popover. The
bar already handles displaying and filtering custom emojis correctly
(including removing stale ones from deleted packs), so the only missing
piece was the tracking call.
2026-03-28 23:07:21 -07:00
Lemon 7966d07158 Fix custom emoji SVGs not rendering in emoji-mart picker
emoji-mart renders custom emoji <img> tags with only max-width/max-height
inline styles but no explicit width/height. SVG files that lack intrinsic
width/height attributes (even with a viewBox) collapse to 0x0 because
max-* constraints alone can't force dimensions on a dimensionless image.

Inject a CSS rule into emoji-mart's shadow DOM that gives custom emoji
images explicit 1em x 1em dimensions with object-fit: contain. The img
lives inside span.emoji-mart-emoji, not a button with data-emoji-set.
2026-03-28 23:07:21 -07:00
Lemon 9ffab3d2dd Fix double-tap reaction not showing emoji on the post
The double-click handler was setting the user-reaction query cache to a
plain string instead of a ResolvedEmoji object. RenderResolvedEmoji
expects { content: '❤️' }, not just '❤️'.
2026-03-28 23:07:21 -07:00
Alex Gleason dbcbd8928b Merge branch 'remove-music-files' into 'main'
Refactor Blobbi music system to use remote Blossom URLs

See merge request soapbox-pub/ditto!137
2026-03-29 03:24:16 +00:00
Chad Curtis a659611897 Fix profile skeleton flicker for new users with no kind:0 metadata 2026-03-28 22:09:53 -05:00
filemon 78b4716a2a Refactor Blobbi music system to use remote Blossom URLs
- Replace local audio files with remote Blossom server URLs
- Remove upload functionality from PlayMusicModal
- Rename BuiltInTrack → BlobbiTrack, AudioSource → SelectedTrack
- Rename blobbi-builtin-tracks.ts → blobbi-track-catalog.ts
- Delete ~4.7MB of local audio files from public/blobbi/audio/
- Fix import path for STAT_MIN/STAT_MAX constants
2026-03-29 00:06:11 -03:00
Chad Curtis 08e26e28d0 Fix key download path: save to Download/ instead of Documents/ 2026-03-28 21:58:53 -05:00
Chad Curtis b1c61a7888 Fix Amber login on Android: retry nostrconnect subscription on foreground resume 2026-03-28 21:51:17 -05:00
Chad Curtis e951a3b00a Fix key download on Android: save to Documents instead of share sheet 2026-03-28 21:47:47 -05:00
Chad Curtis 62b5aab753 Disable DM handler background processing 2026-03-28 21:34:06 -05:00
Alex Gleason 7b307ffe22 Replace window.open() calls with Capacitor-aware openUrl()
window.open() and target="_blank" silently fail inside WKWebView on
iOS. Replace all programmatic window.open() calls with the openUrl()
utility from src/lib/downloadFile.ts, which uses the native share sheet
on Capacitor and falls back to window.open() on web.

Fixed in: ZapDialog, TasksPanel, HatchTasksPanel, PullRequestCard,
CustomNipCard, GitRepoCard, PatchCard.

The middle-click handler in useOpenPost is left as-is since middle-click
is a web-only interaction with no equivalent on mobile.
2026-03-28 21:25:58 -05:00
Alex Gleason edee9f7030 Add iOS privacy manifest and usage description strings
- Add PrivacyInfo.xcprivacy declaring UserDefaults, file timestamp, and
  disk space API usage reasons, plus collected data types for crash
  reporting (Sentry) and analytics (Plausible)
- Add NSPhotoLibraryUsageDescription and NSMicrophoneUsageDescription to
  Info.plist for image uploads and voice message recording

Both are required for App Store submission.
2026-03-28 21:20:32 -05:00
Mary Kate 71949890da Merge branch 'photo-compose-modal' into 'main'
Add dedicated photo upload flow for NIP-68 kind 20 events

See merge request soapbox-pub/ditto!135
2026-03-28 22:03:58 +00:00
Mary Kate Fain 5ae233ff62 Add dedicated photo upload flow for NIP-68 kind 20 events
Enable the floating compose button on the photos page with a camera
icon. Clicking it opens a new PhotoComposeModal with image upload,
title, caption, alt text, and content warning support. Publishes
kind 20 picture events per the NIP-68 specification.
2026-03-28 16:30:21 -05:00
Mary Kate 19400a78e5 Merge branch 'fix/profile-tab-form-reset' into 'main'
Fix custom profile tab form retaining fields from previous tab

Closes #196

See merge request soapbox-pub/ditto!134
2026-03-28 21:08:25 +00:00
Mary Kate Fain 497d6979d0 Fix custom profile tab form retaining fields from previous tab
The ProfileTabEditModal reset form state inside a handleOpenChange
callback, but Radix Dialog does not fire onOpenChange when opened
programmatically via the `open` prop. This meant that when a parent
component set `open={true}` (e.g. after clicking 'Add custom tab'),
the reset logic never ran and the form kept stale values from the
last edit session.

Replace the handleOpenChange reset with a useEffect that triggers
whenever `open` transitions to true, ensuring the form always
initializes from the current `tab` prop (or clean defaults for a
new tab).

Closes #196
2026-03-28 16:01:17 -05:00
Mary Kate 59eab8afea Merge branch 'fix/badge-notification-click' into 'main'
Fix badge notifications not being clickable

Closes #201

See merge request soapbox-pub/ditto!133
2026-03-28 20:56:52 +00:00
Mary Kate Fain 74b84eb5ac Fix badge notifications not being clickable
Wrap badge cards in BadgeAwardNotification and BadgeAwardNotificationGroup
with Link components that navigate to the badge detail page via naddr1
encoded URLs. Both single and grouped badge notifications now link to
the badge definition page when clicked.

Closes #201
2026-03-28 13:38:11 -05:00
Mary Kate bfc864cc7c Merge branch 'rename-vines-to-divines' into 'main'
Rename "Vines" to "Divines" in all user-facing strings

Closes #194

See merge request soapbox-pub/ditto!132
2026-03-28 18:12:28 +00:00
Mary Kate Fain 6c067a3ae6 Rename "Vines" to "Divines" in all user-facing strings
Update sidebar label, page title, feed empty states, search filter
labels, notification kind nouns, comment context labels, and
documentation to use "Divines" instead of "Vines".

Closes #194
2026-03-28 13:05:08 -05:00
Mary Kate 503fed5fdb Merge branch 'changelog-footer-link' into 'main'
Add changelog link to footer section

Closes #203

See merge request soapbox-pub/ditto!131
2026-03-28 17:57:31 +00:00
Mary Kate Fain 32cb3eeba3 Add changelog link to footer section
Closes #203
2026-03-28 12:51:42 -05:00
Mary Kate 7e49e85495 Merge branch 'fix/geocache-treasure-terminology' into 'main'
Fix inconsistent use of 'geocache' vs 'treasures' terminology

Closes #198

See merge request soapbox-pub/ditto!130
2026-03-28 17:49:44 +00:00
Mary Kate Fain c3d7984d7a Fix inconsistent use of 'geocache' vs 'treasures' terminology
Closes https://gitlab.com/soapbox-pub/ditto/-/work_items/198
2026-03-28 12:44:28 -05:00
Mary Kate b024518f5e Merge branch 'fix/double-line-profile-tabs-editing' into 'main'
Fix double line under profile tabs in edit mode

Closes #197

See merge request soapbox-pub/ditto!129
2026-03-28 17:36:53 +00:00
Mary Kate Fain 83c1e9aa6c Fix double line under profile tabs in edit mode
Replace the flat h-1 indicator bar in SortableTabChip with the
SubHeaderBar arc-based active indicator, matching how TabButton works.
The flat bar was overlapping the ArcBackground border stroke, creating
a visible double line when editing profile tabs.
2026-03-28 12:29:38 -05:00
Chad Curtis 8a6cb02dc0 Convert blobbi audio from MP3 to M4A (AAC-LC 32kbps mono), enable R8 shrinking
Replace 36 MB of MP3 files with 4.6 MB of M4A (AAC-LC) files encoded
at 32kbps mono. M4A is required for iOS/Safari compatibility in
Capacitor's WKWebView.

Enable R8 minification and resource shrinking in the Android release
build to further reduce APK size. Add ProGuard rules to keep Capacitor
and OkHttp classes.
2026-03-28 11:35:49 -05:00
Alex Gleason 91237c252c Sanitize Blobbi SVG output with DOMPurify before DOM injection
Add defense-in-depth sanitization at the output boundary of the Blobbi
SVG rendering pipeline. The upstream pipeline validates user inputs
(normalizeHexColor, instanceId regex), but 3000+ lines of regex-based
SVG string manipulation feed directly into dangerouslySetInnerHTML with
no structural guarantee that the output is safe.

sanitizeBlobbiSvg() uses DOMPurify with an allowlist tuned for the
Blobbi pipeline (gradients, clip paths, animations, @keyframes) while
blocking scripts, event handlers, foreignObject, href, and other
dangerous constructs.
2026-03-28 11:33:10 -05:00
278 changed files with 23073 additions and 7205 deletions
+29 -11
View File
@@ -26,19 +26,37 @@ test:
script:
- npm run test
pages:
deploy-nsite:
stage: deploy
timeout: 5 minutes
timeout: 10 minutes
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
NSYTE_VERSION: "v0.24.1"
script:
# Build the web app
- npm ci
- npm run build
- rm -rf public
- mv dist public
artifacts:
paths:
- public
only:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
- cp dist/index.html dist/404.html
# Download nsyte binary
- curl -fsSL "https://github.com/sandwichfarm/nsyte/releases/download/${NSYTE_VERSION}/nsyte-linux" -o /usr/local/bin/nsyte
- chmod +x /usr/local/bin/nsyte
# Deploy to nsite via nsyte using the nbunksec credential
- >-
nsyte deploy ./dist
-i
--sec "$NSITE_NBUNKSEC"
--name ditto
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
--fallback "/index.html"
--publish-server-list
--use-fallback-relays
--use-fallback-servers
build-apk:
stage: build
@@ -198,4 +216,4 @@ publish-zapstore:
- VERSION="${CI_COMMIT_TAG#v}"
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
- zsp publish -y --skip-metadata --skip-preview zapstore.yaml
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
+99 -16
View File
@@ -294,14 +294,16 @@ When adding support for a new Nostr event kind to the application, the kind must
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers
- The icon fallback in `AddressableEventPreview` in the same file
6. **Inline embeds / quote posts** -- events can be quoted inline via `nostr:nevent1...` or `nostr:naddr1...` URIs in note content. Both `EmbeddedNote` and `EmbeddedNaddr` render a compact card (author + title/content preview) for all kinds automatically — no per-kind registration needed. The same components are reused by CommentContext hover cards and the reply composer.
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
> **Note**: Do not confuse these with the `compact` prop on `NoteCard`. The `compact` prop simply hides action buttons on a full `NoteCard`; `EmbeddedNote`/`EmbeddedNaddr` are entirely different components with their own rendering logic.
7. **Reply composer** (`src/components/ReplyComposeModal.tsx`):
- The `EmbeddedPost` component delegates to the shared `EmbeddedNote`/`EmbeddedNaddr` components — no per-kind registration needed
#### Why so many places?
These are genuinely different UI contexts (feed cards, detail pages, inline embeds, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
### NIP.md
@@ -693,6 +695,27 @@ export function MyComponent() {
The `useCurrentUser` hook should be used to ensure that the user is logged in before they are able to publish Nostr events.
### Mutating Replaceable Events (CRITICAL)
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:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const freshEvent = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
// ...modify tags...
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
@@ -954,6 +977,16 @@ const defaultConfig: AppConfig = {
The app uses NIP-65 compatible relay management with automatic sync when users log in. Local storage persists user preferences and relay configurations.
### Adding a New AppConfig Value
Adding a new configuration field requires updates in **three places**. Missing any of them will cause build failures or runtime issues.
1. **TypeScript interface** (`src/contexts/AppContext.ts`): Add the field to the `AppConfig` interface with a JSDoc comment.
2. **Zod schema** (`src/lib/schemas.ts`): Add the same field to `AppConfigSchema`. The `DittoConfigSchema` (used to validate the build-time `ditto.json` file) is derived from `AppConfigSchema` with `.strict()` mode, so any field present in `ditto.json` but missing from the Zod schema will cause a build error.
3. **Default value** (`src/contexts/AppContext.ts`): If the field is required (not optional), add a default value in `defaultConfig`. Optional fields (`?` in the interface, `.optional()` in Zod) can be omitted from the default.
### Relay Management
The project includes a complete NIP-65 relay management system:
@@ -1315,7 +1348,7 @@ After adding or removing plugins, run `npx cap sync` to update the native projec
The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
1. **test** - Runs `npm run test` on every commit (skipped for tags)
2. **deploy** - Builds and deploys to GitLab Pages (default branch only)
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
@@ -1355,19 +1388,69 @@ NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
**Initial setup (one-time):**
1. Generate a client key: `nak key generate` (save the hex output)
2. Store it as `ZAPSTORE_CLIENT_KEY` in GitLab CI/CD variables
3. Get a bunker URL from Amber (with `secret` param for first connection)
4. Authorize the client key locally using `nak`:
```bash
export NOSTR_CLIENT_KEY="<the hex client key>"
nak event --sec "bunker://<pubkey>?relay=...&secret=<secret>" -c "test"
```
5. Approve the connection on Amber when prompted
6. Store the bunker URL **without the `secret` param** as `ZAPSTORE_BUNKER_URL` in GitLab CI/CD variables (the secret is single-use and no longer needed after authorization)
Run the NIP-46 client-initiated auth script:
```bash
node scripts/nip46-auth.mjs
```
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script will then output the `bunker://` URI and client key hex, and write the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
The script accepts options:
- `--relay <url>` -- relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
- `--name <name>` -- app name shown to the signer (default: `Ditto`)
- `--timeout <sec>` -- how long to wait for approval (default: 300)
**Key points:**
- The `secret` in bunker URLs is **single-use** -- it is consumed on first connection and cannot be reused
- The `ZAPSTORE_CLIENT_KEY` must be authorized locally first by connecting to the bunker with a fresh secret and approving on Amber
- After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs
- If the client key is rotated, the authorization step must be repeated with a new bunker URL secret
- If the client key is rotated, run the script again and update the GitLab CI/CD variables
### nsite Publishing
The project automatically deploys the web app to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The `deploy-nsite` CI job builds the Vite app and uploads the `dist/` directory to Blossom servers, publishing site manifest events to Nostr relays.
nsyte uses a NIP-46 bunker credential called `nbunksec` -- a bech32-encoded string that bundles the bunker pubkey, client secret key, and relay info into a single self-contained token. This is passed to nsyte via `--sec`.
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
#### Initial Setup (one-time)
1. Install nsyte locally:
```bash
curl -fsSL https://nsyte.run/get/install.sh | bash
```
2. Generate the CI credential:
```bash
nsyte ci
```
This will guide you through connecting a NIP-46 bunker (e.g. Amber) and output an `nbunksec1...` string. The credential is shown only once.
3. Add the `nbunksec1...` value as the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
#### Configured Relays and Servers
The deploy job publishes to these relays:
- `wss://relay.ditto.pub`
- `wss://relay.nsite.lol`
- `wss://relay.dreamith.to`
- `wss://relay.primal.net`
And uploads blobs to these Blossom servers:
- `https://blossom.primal.net`
- `https://blossom.ditto.pub`
- `https://blossom.dreamith.to`
The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
#### Credential Rotation
To rotate the nsite credential:
1. Revoke the old bunker connection in your signer app
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
+141 -1
View File
@@ -1,5 +1,145 @@
# Changelog
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps (NIP-89) now display in feeds and detail pages with hero images, icons, supported kinds, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
@@ -17,7 +157,7 @@
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen vines experience on both mobile and desktop with floating controls
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
+68 -1
View File
@@ -2,12 +2,31 @@
## Event Kinds Overview
### Ditto Kinds
| Kind | Name | Description |
|-------|----------------------|-------------------------------------------------------|
| 36767 | Theme Definition | Shareable, named custom UI theme |
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery |
### Community Kinds
These event kinds were created by community contributors and are supported by Ditto. Full specifications are maintained by their respective authors.
| Kind | Name | Description | Spec |
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
| 11125 | Blobbonaut Profile | Owner profile with coins, achievements, and inventory | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14919 | Blobbi Interaction | Individual pet interaction (feed, play, clean, etc.) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14920 | Blobbi Breeding | Breeding event between two adult Blobbis | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 14921 | Blobbi Record | Immutable lifecycle record (birth, evolution, adoption) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
| 31124 | Blobbi Pet State | Current state of a virtual Blobbi pet (addressable) | [NIP-BB](https://github.com/Danidfra/nostr-pet/blob/production/NIP.md) |
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
---
@@ -294,3 +313,51 @@ The `shape` field is added to the JSON content of a kind 0 event alongside stand
- The `shape` field is purely cosmetic and has no protocol-level significance.
- Clients MAY choose not to support this extension, in which case avatars render as circles as usual.
---
## Community NIP Specifications
The following specifications are maintained by their respective authors. Ditto implements these kinds but does not own the specs. See each link for the full event structure, tags, and client behavior.
### Color Moments (Kind 3367)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md
**App:** https://espy.you
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
### Geocaching (Kinds 37516, 7516)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md
**App:** https://treasures.to
NIP-GC defines geocaching on Nostr. Kind 37516 (addressable) is a geocache listing with location (geohash), difficulty/terrain scores, size, and type. Kind 7516 is a found log recording a successful visit. The spec also covers comment logs (kind 1111 via NIP-22), verified finds with cryptographic proof (kind 7517), and cache retirement.
### Personal Letters (Kind 8211)
**Author:** Chad Curtis
**Spec:** https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md
**App:** https://lief.to
NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, decorative frames, and custom fonts. Letters render as 5:4 landscape postcards. The privacy model is intentionally postcard-like: sender/recipient metadata is visible, content is encrypted.
### Weather Station (Kinds 4223, 16158)
**Author:** Sam Thomson
**Spec:** https://github.com/nostr-protocol/nips/pull/2163
**App:** https://weather.shakespeare.wtf
**Firmware:** https://github.com/samthomson/weather-station
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
### Blobbi Virtual Pet (Kinds 31124, 14919, 14920, 14921, 11125)
**Author:** Danifra
**Spec:** https://github.com/Danidfra/nostr-pet/blob/production/NIP.md
**App:** https://nostr-pet.vercel.app
**See also:** [Blobbi tag schema](docs/blobbi/blobbi-tag-schema.md) (Ditto-specific integration details)
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
+1 -1
View File
@@ -13,7 +13,7 @@ Made by [Soapbox](https://soapbox.pub).
## Features
- **Theming** -- 9 built-in theme presets, 19 CSS token properties for full customization, and the ability to publish and share themes as Nostr events
- **Infinite Content Types** -- Text notes, articles, short-form videos (Vines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
- **Infinite Content Types** -- Text notes, articles, short-form videos (Divines), live streams, polls, follow packs, color moments, magic decks, geocaching, and Webxdc mini-apps
- **Lightning Payments** -- Zap posts and profiles with sats via Nostr Wallet Connect (NWC) or WebLN
- **Private Messaging** -- End-to-end encrypted DMs (NIP-04 and NIP-17)
- **Comments** -- Comment on anything: posts, URLs, profiles, hashtags, books, and more (NIP-22)
+4 -3
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.2.1"
versionName "2.2.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -35,8 +35,9 @@ android {
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
+13 -6
View File
@@ -5,12 +5,19 @@
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Keep Capacitor classes (WebView JS bridge)
-keep class com.getcapacitor.** { *; }
-keep class pub.ditto.app.** { *; }
# Keep WebView JS interfaces
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Keep OkHttp (used by Capacitor)
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
+1 -1
View File
@@ -60,7 +60,7 @@ const builtinThemes = {
};
```
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
### ThemeConfig
+1 -2
View File
@@ -127,7 +127,6 @@ User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
@@ -192,7 +191,7 @@ These tags are from legacy versions and MUST be removed when republishing events
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`visible_to_others`, `breeding_ready`)
- All social tags (`breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.1;
MARKETING_VERSION = 2.2.11;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.1;
MARKETING_VERSION = 2.2.11;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+4
View File
@@ -47,5 +47,9 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Ditto needs access to your microphone to record voice messages.</string>
</dict>
</plist>
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<!-- Crash / performance data via Sentry/GlitchTip -->
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<!-- Performance / analytics data via Plausible -->
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePerformanceData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<!-- UserDefaults — used by Capacitor/WKWebView for localStorage -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- CA92.1: Access info from same app -->
<string>CA92.1</string>
</array>
</dict>
<!-- File timestamp APIs — used by @capacitor/filesystem -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- C617.1: Access file timestamps inside app container -->
<string>C617.1</string>
</array>
</dict>
<!-- Disk space APIs — used by WKWebView / file operations -->
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- E174.1: Check available disk space before writing -->
<string>E174.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+15 -5
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.1.1",
"version": "2.2.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.1.1",
"version": "2.2.10",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -38,6 +38,7 @@
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/noto-sans-nushu": "^5.2.6",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
@@ -989,6 +990,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/noto-sans-nushu": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-nushu/-/noto-sans-nushu-5.2.6.tgz",
"integrity": "sha512-YZswFaWI+EspK69GAg0o53WPXsaYu89dhbjwMYvIFVaRTSYKfcLSdTVCksPQ4ClyXpbWEAmsP+MxRlYlV4kM5g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pacifico": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.2.7.tgz",
@@ -6044,9 +6054,9 @@
"license": "unlicense"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"dev": true,
"license": "MIT",
"engines": {
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.1",
"version": "2.2.11",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -44,6 +44,7 @@
"@fontsource/courier-prime": "^5.2.8",
"@fontsource/creepster": "^5.2.7",
"@fontsource/luckiest-guy": "^5.2.8",
"@fontsource/noto-sans-nushu": "^5.2.6",
"@fontsource/pacifico": "^5.2.7",
"@fontsource/permanent-marker": "^5.2.7",
"@fontsource/pirata-one": "^5.2.8",
+141 -1
View File
@@ -1,5 +1,145 @@
# Changelog
## [2.2.11] - 2026-04-02
### Fixed
- Fix crash caused by the "What's new" toast firing outside the router
## [2.2.10] - 2026-04-02
### Added
- App cards for Nostr apps (NIP-89) now display in feeds and detail pages with hero images, icons, supported kinds, and quick-launch buttons
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
### Changed
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
### Fixed
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
## [2.2.9] - 2026-04-01
### Added
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
- Blobbi companions now appear in feeds and post detail pages
### Changed
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
- Emoji packs without any valid emojis are now hidden from feeds
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
## [2.2.8] - 2026-04-01
### Added
- Full threaded reply trees on post detail pages with collapsible deep branches and "Show X more replies" for sibling threads
- Broadcast button in the Event JSON dialog to re-publish any event to your relays
### Changed
- My Badges tab overhauled with drag-and-drop reordering, a scrollable list, and a showcase-style carousel for pending badges
- Encrypted letter envelopes now show the mailing side first (sender and recipient), then flip to reveal the wax seal
- Blobbi companions are more expressive -- richer status reactions, sleeping visuals, and body effects like dirt and hunger cues
### Fixed
- Notification dot not clearing after marking notifications as read
- Followers/following modal staying open after navigating to a profile
## [2.2.7] - 2026-03-31
### Fixed
- Nushu script in encrypted letters now renders correctly on Android and iOS
## [2.2.6] - 2026-03-31
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
### Changed
- Post action buttons extracted into a reusable PostActionBar component
- Badge detail page streamlined with unified tab bar
### Fixed
- Hashtags now support accented and Unicode characters
- Letter compose opens correctly from notifications and the letters page
- Letter font picker loads fonts so each option previews in the correct typeface
- Zap comment positioned inside the right column instead of floating with offset
- Safe-area padding on pinned SubHeaderBar only applies when scrolled to top
## [2.2.5] - 2026-03-30
### Fixed
- Crash when dragging profile tabs to reorder them
## [2.2.4] - 2026-03-30
### Changed
- Profiles now have an emoji reaction button instead of a zap button -- express yourself with any emoji or custom emoji right on someone's profile
- Zap moved to the profile overflow menu so it's still one tap away
### Fixed
- Crash on the notifications page caused by malformed badge award tags
- Deleting a badge now also deletes all awards you issued for it
- Custom emoji reactions missing their image tag no longer render as broken shortcodes
- Deletion requests for addressable events now include both `e` and `a` tags for broader relay compatibility
- Profile reactions no longer collapse into a single grouped notification
- Oversized reaction emoji in comment context headers
## [2.2.3] - 2026-03-30
### Added
- Letters now have an overflow menu, reply button, and a grid layout for browsing
- Independent feed toggles for comments and generic reposts in content settings
- Sidebar items are now visible to logged-out users so newcomers can explore everything
### Changed
- Compose textarea expands smoothly as you type instead of snapping to a new height
- Blobbi stickers auto-shrink near card edges and clip cleanly at rounded boundaries
### Fixed
- Feed gaps when replies are disabled no longer cause missing posts
- Avatar shape no longer flashes on load
- Top bar arc no longer flickers during navigation transitions
- Letter drawing-only sends, sticker drag bounds, and theme event preservation
- Notification rendering for badges and letters
- Duplicate React keys in content settings
- Layout rendering warning when switching views
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- Pull-to-refresh on all feed pages
- 3D tilt effect on badge images -- hover over badges to see them pop
- Multi-select badge awarding with indicators for already-sent badges
- Badge list recovery dialog for restoring kind 10008 profile badge lists
- Compact badge row preview in embedded profile badges events
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
- Release notes now included in Zapstore publishing
- Changelog link in the app footer
### Changed
- "Vines" renamed to "Divines" everywhere in the app
- Custom emojis appear first in the emoji picker, right after recent
- Threaded comment view now shows the parent event as a NoteCard with kind action headers
### Fixed
- Delete post dialog no longer freezes the feed on desktop
- Amber login on Android now properly retries when returning from the background
- Key downloads on Android save to the correct location
- Custom emoji SVGs render correctly in the emoji-mart picker
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- Badge notifications are now clickable
- Custom profile tab form no longer retains fields from a previously edited tab
- Double line under profile tabs in edit mode
- Inconsistent use of "geocache" vs "treasures" terminology
- Search page "N new posts" pill no longer shows unfiltered count
- Stale-cache overwrites in replaceable event mutations
- Click-through on delete confirmation and note menu items
## [2.2.1] - 2026-03-28
### Fixed
@@ -17,7 +157,7 @@
- Blobbi companion that follows you around the app, tracks your cursor, blinks, expresses emotions, and reacts to what you're doing
- Blobbi shop and inventory system with items that affect your pet's stats
- Daily missions with reroll, care streaks, and stage-based rewards
- Immersive full-screen vines experience on both mobile and desktop with floating controls
- Immersive full-screen divines experience on both mobile and desktop with floating controls
- NIP-11 relay information panel on the network settings page
- Link preview cards now display inside quoted posts instead of raw URLs
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* NIP-46 Client-Initiated Auth Script
*
* Generates an ephemeral client keypair and a `nostrconnect://` URI.
* Import the URI into a remote signer app (e.g. Amber) to authorize
* the client key. Once authorized, the script outputs:
*
* - bunker:// URI (for ZAPSTORE_BUNKER_URL)
* - client secret key hex (for ZAPSTORE_CLIENT_KEY)
*
* It also writes the client key to ~/.config/zsp/bunker-keys/<bunkerPubkey>.key
* so that `zsp` can use it immediately.
*
* Usage:
* node scripts/nip46-auth.mjs [--relay wss://relay.example.com] [--name MyApp] [--timeout 300]
*/
import { NPool, NRelay1, NConnectSigner, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
import QRCode from 'qrcode';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const result = {
relays: [],
name: 'Ditto',
timeout: 300, // seconds
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--relay':
result.relays.push(args[++i]);
break;
case '--name':
result.name = args[++i];
break;
case '--timeout':
result.timeout = parseInt(args[++i], 10);
break;
case '--help':
case '-h':
console.log(`Usage: node scripts/nip46-auth.mjs [options]
Options:
--relay <url> Relay URL for NIP-46 communication (repeatable)
Default: wss://relay.ditto.pub
--name <name> Application name shown to the signer
Default: Ditto
--timeout <sec> How long to wait for signer approval (seconds)
Default: 300 (5 minutes)
--help, -h Show this help message
`);
process.exit(0);
}
}
if (result.relays.length === 0) {
result.relays.push('wss://relay.ditto.pub');
}
return result;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
// 1. Generate ephemeral client keypair
const clientSecretKey = generateSecretKey();
const clientPubkey = getPublicKey(clientSecretKey);
const clientHex = bytesToHex(clientSecretKey);
console.log('');
console.log('=== NIP-46 Client-Initiated Auth ===');
console.log('');
console.log(`Client pubkey: ${clientPubkey}`);
console.log(`Relay(s): ${opts.relays.join(', ')}`);
console.log(`Timeout: ${opts.timeout}s`);
console.log('');
// 2. Generate random secret
const randomBytes = new Uint8Array(4);
crypto.getRandomValues(randomBytes);
const secret = Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// 3. Build nostrconnect:// URI
const searchParams = new URLSearchParams();
for (const relay of opts.relays) {
searchParams.append('relay', relay);
}
searchParams.set('secret', secret);
searchParams.set('name', opts.name);
const nostrConnectURI = `nostrconnect://${clientPubkey}?${searchParams.toString()}`;
console.log('Scan this QR code with your signer app (e.g. Amber):');
console.log('');
console.log(await QRCode.toString(nostrConnectURI, { type: 'terminal', small: true }));
console.log('Or import this URI manually:');
console.log('');
console.log(` ${nostrConnectURI}`);
console.log('');
console.log('Waiting for signer to approve the connection...');
console.log('');
// 4. Set up relay pool
const pool = new NPool({
open: (url) => new NRelay1(url),
reqRouter: async (filters) => new Map(opts.relays.map((r) => [r, filters])),
eventRouter: async () => opts.relays,
});
const clientSigner = new NSecSigner(clientSecretKey);
const relayGroup = pool.group(opts.relays);
// 5. Subscribe and wait for the signer's response
const signal = AbortSignal.timeout(opts.timeout * 1000);
const sub = relayGroup.req(
[{ kinds: [24133], '#p': [clientPubkey], limit: 1 }],
{ signal },
);
let bunkerPubkey;
let userPubkey;
try {
for await (const msg of sub) {
if (msg[0] === 'CLOSED') {
throw new Error('Relay closed the subscription before signer responded');
}
if (msg[0] === 'EVENT') {
const event = msg[2];
let decrypted;
try {
decrypted = await clientSigner.nip44.decrypt(event.pubkey, event.content);
} catch {
// Could not decrypt -- not for us, skip
continue;
}
let response;
try {
response = JSON.parse(decrypted);
} catch {
continue;
}
if (response.result !== secret && response.result !== 'ack') {
continue;
}
bunkerPubkey = event.pubkey;
console.log(`Signer responded! Bunker pubkey: ${bunkerPubkey}`);
console.log('');
// 6. Get user pubkey via the now-established connection
const signer = new NConnectSigner({
relay: relayGroup,
pubkey: bunkerPubkey,
signer: clientSigner,
timeout: 60_000,
});
console.log('Requesting user public key...');
userPubkey = await signer.getPublicKey();
console.log(`User pubkey: ${userPubkey}`);
console.log('');
break;
}
}
} catch (err) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
console.error(`Timed out after ${opts.timeout}s waiting for signer approval.`);
console.error('Make sure you imported the nostrconnect:// URI into your signer app.');
process.exit(1);
}
throw err;
}
if (!bunkerPubkey || !userPubkey) {
console.error('Failed to establish connection with remote signer.');
process.exit(1);
}
// 7. Build bunker:// URI (for CI)
const bunkerParams = new URLSearchParams();
for (const relay of opts.relays) {
bunkerParams.append('relay', relay);
}
const bunkerURI = `bunker://${bunkerPubkey}?${bunkerParams.toString()}`;
// 8. Write client key to zsp config
const zspDir = path.join(os.homedir(), '.config', 'zsp', 'bunker-keys');
const zspKeyFile = path.join(zspDir, `${bunkerPubkey}.key`);
fs.mkdirSync(zspDir, { recursive: true });
fs.writeFileSync(zspKeyFile, clientHex + '\n', { mode: 0o600 });
// 9. Print results
console.log('=== Connection Established ===');
console.log('');
console.log('Bunker URI (ZAPSTORE_BUNKER_URL):');
console.log(` ${bunkerURI}`);
console.log('');
console.log('Client secret key hex (ZAPSTORE_CLIENT_KEY):');
console.log(` ${clientHex}`);
console.log('');
console.log(`User pubkey: ${userPubkey}`);
console.log(`User npub: ${nip19.npubEncode(userPubkey)}`);
console.log('');
console.log(`zsp client key written to: ${zspKeyFile}`);
console.log('');
console.log('Next steps:');
console.log(' 1. Update ZAPSTORE_BUNKER_URL in GitLab CI/CD variables');
console.log(' 2. Update ZAPSTORE_CLIENT_KEY in GitLab CI/CD variables');
console.log('');
// Clean up
pool.close();
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err.message);
process.exit(1);
});
+29 -10
View File
@@ -16,17 +16,19 @@ import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
const dmConfig: DMConfig = {
enabled: true,
enabled: false,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
@@ -59,7 +61,9 @@ const hardcodedConfig: AppConfig = {
},
feedSettings: {
feedIncludePosts: true,
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeArticles: true,
showArticles: true,
showEvents: true,
@@ -112,17 +116,17 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
"feed",
"notifications",
"search",
"bookmarks",
"profile",
"photos",
"videos",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"settings",
"help",
@@ -146,15 +150,30 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
};
/**
* Parse and validate build-time ditto.json overrides from the env string.
* Returns an empty object when no config file was provided or validation fails.
*/
function parseDittoConfig(): DittoConfig {
try {
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
if (!json) return {};
return DittoConfigSchema.parse(json);
} catch {
return {};
}
}
/**
* Merge hardcoded defaults with build-time ditto.json overrides.
* Deep-merges feedSettings so a partial override doesn't erase defaults.
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
*/
const dittoConfig = parseDittoConfig();
const defaultConfig: AppConfig = {
...hardcodedConfig,
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
? __DITTO_CONFIG__
: {}),
...dittoConfig,
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
};
export function App() {
@@ -182,11 +201,11 @@ export function App() {
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
+33 -10
View File
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
@@ -22,6 +24,9 @@ const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => (
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
@@ -45,6 +50,7 @@ const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default:
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const HelpPage = lazy(() => import("./pages/HelpPage").then(m => ({ default: m.HelpPage })));
const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ default: m.KindFeedPage })));
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
@@ -70,6 +76,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -99,6 +106,26 @@ function PollsFeedPage() {
);
}
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
function EmojiFeedPage() {
const [composeOpen, setComposeOpen] = useState(false);
return (
<>
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
{composeOpen && (
<Suspense fallback={null}>
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
</Suspense>
)}
</>
);
}
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
function ProfileRedirect() {
const { user, metadata } = useCurrentUser();
@@ -111,6 +138,8 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
@@ -202,16 +231,7 @@ export function AppRouter() {
/>
}
/>
<Route
path="/emojis"
element={
<KindFeedPage
kind={emojisDef.kind}
title={emojisDef.label}
icon={sidebarItemIcon("emojis", "size-5")}
/>
}
/>
<Route path="/emojis" element={<EmojiFeedPage />} />
<Route
path="/development"
element={
@@ -237,6 +257,7 @@ export function AppRouter() {
<Route path="/bluesky" element={<BlueskyPage />} />
<Route path="/wikipedia" element={<WikipediaPage />} />
<Route path="/letters" element={<LettersPage />} />
<Route path="/letters/compose" element={<LetterComposePage />} />
<Route path="/settings/letters" element={<LetterPreferencesPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
@@ -249,6 +270,8 @@ export function AppRouter() {
/>
<Route path="/i/*" element={<ExternalContentPage />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
@@ -15,7 +15,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import {
@@ -11,7 +11,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
@@ -27,7 +27,7 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useState } from 'react';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { NostrEvent } from '@nostrify/nostrify';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
@@ -7,6 +7,7 @@
import { ExternalLink, Check, Loader2, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
@@ -48,7 +49,7 @@ function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
navigate(task.actionTarget);
break;
case 'external_link':
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
@@ -9,14 +9,14 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import type { AudioSource } from './PlayMusicModal';
import type { SelectedTrack } from './PlayMusicModal';
// Re-export for external use
export type { AudioSource as MusicTrackSource } from './PlayMusicModal';
export type { SelectedTrack } from './PlayMusicModal';
interface InlineMusicPlayerProps {
/** The selected track source */
source: AudioSource;
/** The selected track */
selection: SelectedTrack;
/** Called when user wants to change the track */
onChangeTrack: () => void;
/** Called when user closes the player */
@@ -34,7 +34,7 @@ interface InlineMusicPlayerProps {
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineMusicPlayer({
source,
selection,
onChangeTrack,
onClose,
onPlaybackStart,
@@ -64,20 +64,20 @@ export function InlineMusicPlayer({
// that requires explicit user action (play button) to restart
useEffect(() => {
if (isPublished && playbackState === 'idle') {
load(source.url, true);
load(selection.url, true);
onPlaybackStart?.();
}
}, [isPublished, playbackState, source.url, load, onPlaybackStart]);
}, [isPublished, playbackState, selection.url, load, onPlaybackStart]);
// Force reload when source URL changes while already playing/paused
useEffect(() => {
// Only trigger reload if we're in an active playback state with a different URL
if (isPublished && (playbackState === 'playing' || playbackState === 'paused')) {
// The load function will check if URL changed and reload if needed
load(source.url, true);
load(selection.url, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to source.url changes
}, [source.url]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
}, [selection.url]);
// Notify on playback state changes
useEffect(() => {
@@ -99,20 +99,15 @@ export function InlineMusicPlayer({
// Handle play/pause toggle
const handleToggle = useCallback(async () => {
if (playbackState === 'idle' || playbackState === 'stopped') {
load(source.url, true);
load(selection.url, true);
} else {
await toggle();
}
}, [playbackState, source.url, load, toggle]);
}, [playbackState, selection.url, load, toggle]);
// Track title
const trackTitle = source.type === 'builtin'
? source.track?.title ?? 'Unknown Track'
: source.file?.name ?? 'Uploaded Track';
const trackArtist = source.type === 'builtin'
? source.track?.artist
: undefined;
// Track info
const trackTitle = selection.track.title;
const trackArtist = selection.track.artist;
const isLoading = playbackState === 'loading' || isPublishing;
const hasError = playbackState === 'error';
+56 -200
View File
@@ -1,7 +1,7 @@
// src/blobbi/actions/components/PlayMusicModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Music, Upload, Play, Pause, Check, Loader2, Volume2, X, AlertCircle } from 'lucide-react';
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
import {
Dialog,
@@ -10,30 +10,29 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import {
getAllBuiltInTracks,
getAllTracks,
formatTrackDuration,
type BuiltInTrack,
} from '../lib/blobbi-builtin-tracks';
type BlobbiTrack,
} from '../lib/blobbi-track-catalog';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Audio source for the music player
* Selected track for the music player
*/
export type AudioSource =
| { type: 'builtin'; track: BuiltInTrack; url: string }
| { type: 'uploaded'; file: File; url: string };
export interface SelectedTrack {
track: BlobbiTrack;
url: string;
}
interface PlayMusicModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the selected audio source when user confirms */
onConfirm: (source: AudioSource) => void;
/** Called with the selected track when user confirms */
onConfirm: (selection: SelectedTrack) => void;
isLoading: boolean;
}
@@ -45,102 +44,53 @@ export function PlayMusicModal({
onConfirm,
isLoading,
}: PlayMusicModalProps) {
const [selectedSource, setSelectedSource] = useState<AudioSource | null>(null);
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [builtInError, setBuiltInError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const builtInTracks = getAllBuiltInTracks();
// Track the current audio source URL to detect changes
const currentAudioUrlRef = useRef<string | null>(null);
// Cleanup audio on unmount or modal close
const tracks = getAllTracks();
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke object URL if it was an uploaded file
if (selectedSource?.type === 'uploaded') {
URL.revokeObjectURL(selectedSource.url);
}
};
}, [selectedSource]);
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
setSelectedSource(null);
setSelectedTrack(null);
setIsPlaying(false);
setUploadError(null);
setBuiltInError(null);
setError(null);
currentAudioUrlRef.current = null;
}
}, [open]);
// Handle selecting a built-in track
const handleSelectBuiltIn = useCallback((track: BuiltInTrack) => {
// Handle selecting a track
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
// Revoke previous URL if uploaded
if (selectedSource?.type === 'uploaded') {
URL.revokeObjectURL(selectedSource.url);
}
setSelectedSource({ type: 'builtin', track, url: track.path });
setBuiltInError(null);
}, [selectedSource]);
// Handle file upload
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/mp4'];
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|m4a)$/i)) {
setUploadError('Please upload an MP3, WAV, OGG, or M4A file.');
return;
}
// Validate file size (max 10MB)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
setUploadError('File is too large. Maximum size is 10MB.');
return;
}
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
// Revoke previous URL if uploaded
if (selectedSource?.type === 'uploaded') {
URL.revokeObjectURL(selectedSource.url);
}
const url = URL.createObjectURL(file);
setSelectedSource({ type: 'uploaded', file, url });
setUploadError(null);
}, [selectedSource]);
// Track the current audio source URL to detect changes
const currentAudioUrlRef = useRef<string | null>(null);
setSelectedTrack({ track, url: track.url });
setError(null);
}, []);
// Handle play/pause preview
const handleTogglePlay = useCallback(() => {
if (!selectedSource) return;
if (!selectedTrack) return;
const audioUrl = selectedSource.type === 'builtin'
? selectedSource.track.path
: selectedSource.url;
const audioUrl = selectedTrack.url;
// Check if we need to create a new Audio instance (source changed or first time)
const needsNewAudio = !audioRef.current || currentAudioUrlRef.current !== audioUrl;
@@ -159,9 +109,7 @@ export function PlayMusicModal({
audioRef.current.onended = () => setIsPlaying(false);
audioRef.current.onerror = () => {
if (selectedSource.type === 'builtin') {
setBuiltInError('This track is not available yet. Try uploading your own music!');
}
setError('Failed to load this track. Please try another one.');
setIsPlaying(false);
};
}
@@ -173,26 +121,24 @@ export function PlayMusicModal({
} else {
// Start playback (either new source or resuming)
audioRef.current?.play().catch(() => {
if (selectedSource.type === 'builtin') {
setBuiltInError('This track is not available yet. Try uploading your own music!');
}
setError('Failed to play this track. Please try another one.');
setIsPlaying(false);
});
setIsPlaying(true);
}
}, [selectedSource, isPlaying]);
}, [selectedTrack, isPlaying]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (!selectedSource) return;
if (!selectedTrack) return;
// Stop playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onConfirm(selectedSource);
}, [selectedSource, onConfirm]);
onConfirm(selectedTrack);
}, [selectedTrack, onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
@@ -202,12 +148,6 @@ export function PlayMusicModal({
}
onOpenChange(isOpen);
}, [onOpenChange]);
const selectedName = selectedSource?.type === 'builtin'
? selectedSource.track.title
: selectedSource?.type === 'uploaded'
? selectedSource.file.name
: null;
return (
<Dialog open={open} onOpenChange={handleClose}>
@@ -227,116 +167,32 @@ export function PlayMusicModal({
</div>
</DialogHeader>
{/* Content */}
{/* Content - Track List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<Tabs defaultValue="builtin" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="builtin">Built-in</TabsTrigger>
<TabsTrigger
value="upload"
disabled
className="gap-1.5 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
>
Upload
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-normal">
Soon
</Badge>
</TabsTrigger>
</TabsList>
{/* Built-in Tracks Tab */}
<TabsContent value="builtin" className="mt-4">
<div className="grid gap-2">
{builtInTracks.map((track) => (
<TrackRow
key={track.id}
track={track}
isSelected={selectedSource?.type === 'builtin' && selectedSource.track.id === track.id}
onSelect={() => handleSelectBuiltIn(track)}
/>
))}
<div className="grid gap-2">
{tracks.map((track) => (
<TrackRow
key={track.id}
track={track}
isSelected={selectedTrack?.track.id === track.id}
onSelect={() => handleSelectTrack(track)}
/>
))}
</div>
{error && (
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{error}</p>
</div>
{builtInError && (
<div className="mt-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">{builtInError}</p>
</div>
</div>
)}
</TabsContent>
{/* Upload Tab */}
<TabsContent value="upload" className="mt-4">
<div className="space-y-4">
{/* Upload Area */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={cn(
"w-full p-8 rounded-xl border-2 border-dashed transition-colors",
"hover:border-primary/50 hover:bg-primary/5",
"flex flex-col items-center justify-center gap-3",
selectedSource?.type === 'uploaded'
? "border-primary/30 bg-primary/5"
: "border-border"
)}
>
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Upload className="size-6 text-muted-foreground" />
</div>
<div className="text-center">
<p className="font-medium">Upload Audio File</p>
<p className="text-sm text-muted-foreground">
MP3, WAV, OGG, M4A (max 10MB)
</p>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileUpload}
className="hidden"
/>
{/* Upload Error */}
{uploadError && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<X className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{uploadError}</p>
</div>
</div>
)}
{/* Uploaded File Display */}
{selectedSource?.type === 'uploaded' && (
<div className="p-4 rounded-xl border bg-card/60">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Music className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedSource.file.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedSource.file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Check className="size-5 text-primary shrink-0" />
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
{/* Preview Controls */}
{selectedSource && (
{selectedTrack && (
<div className="mb-4 p-3 rounded-lg bg-card border">
<div className="flex items-center gap-3">
<Button
@@ -352,7 +208,7 @@ export function PlayMusicModal({
)}
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate text-sm">{selectedName}</p>
<p className="font-medium truncate text-sm">{selectedTrack.track.title}</p>
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : 'Click to preview'}
</p>
@@ -376,7 +232,7 @@ export function PlayMusicModal({
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedSource || isLoading}
disabled={!selectedTrack || isLoading}
className="flex-1"
>
{isLoading ? (
@@ -401,7 +257,7 @@ export function PlayMusicModal({
// ─── Track Row Component ──────────────────────────────────────────────────────
interface TrackRowProps {
track: BuiltInTrack;
track: BlobbiTrack;
isSelected: boolean;
onSelect: () => void;
}
@@ -22,7 +22,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -26,7 +26,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -8,6 +8,7 @@
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
@@ -62,7 +63,7 @@ function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
navigate(task.actionTarget);
break;
case 'external_link':
window.open(task.actionTarget, '_blank', 'noopener,noreferrer');
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
@@ -17,7 +17,7 @@
*/
import { useMemo } from 'react';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTask, HatchTasksResult } from './useHatchTasks';
import type { EvolveTasksResult } from './useEvolveTasks';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
@@ -23,8 +23,8 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
@@ -6,12 +6,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import {
clampStat,
applyStat,
@@ -22,6 +22,7 @@ import {
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
@@ -50,6 +51,8 @@ export interface DirectActionRequest {
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
xpGained: number;
newXP: number;
}
/**
@@ -129,6 +132,9 @@ export function useBlobbiDirectAction({
const happinessDelta = DIRECT_ACTION_HAPPINESS_EFFECTS[action];
const newHappiness = applyStat(statsAfterDecay.happiness, happinessDelta);
// Track if happiness actually changed
const happinessChanged = newHappiness !== statsAfterDecay.happiness;
// Build stats update
const isEgg = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {
@@ -161,9 +167,16 @@ export function useBlobbiDirectAction({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (ONLY if happiness actually changed) ───
// Direct actions modify happiness. Only grant XP if happiness actually increased.
const xpGained = happinessChanged ? calculateActionXP(action) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
@@ -185,13 +198,16 @@ export function useBlobbiDirectAction({
return {
action,
happinessChange: happinessDelta,
xpGained,
newXP,
};
},
onSuccess: ({ action, happinessChange }) => {
onSuccess: ({ action, happinessChange, xpGained }) => {
const actionMeta = DIRECT_ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} complete!`,
description: `Your Blobbi's happiness increased by ${happinessChange}!`,
description: `Your Blobbi's happiness increased by ${happinessChange}! ${xpText}`,
});
// Track daily mission progress
@@ -21,12 +21,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -62,7 +62,7 @@ export interface UseStartIncubationParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -325,7 +325,7 @@ export interface UseStopIncubationParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -476,7 +476,7 @@ export interface UseStartEvolutionParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -627,7 +627,7 @@ export interface UseStopEvolutionParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -780,7 +780,7 @@ export interface UseSyncTaskCompletionsParams {
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -19,14 +19,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
STAT_MAX,
updateBlobbiTags,
DEFAULT_EGG_STATS,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/lib/blobbi-tag-schema';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { validateAndRepairBlobbiTags } from '@/blobbi/core/lib/blobbi-tag-schema';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
// ─── Content Helpers ──────────────────────────────────────────────────────────
@@ -56,7 +56,7 @@ export interface CanonicalActionResult {
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
}
/**
@@ -157,14 +157,13 @@ export function useBlobbiHatch({
});
// ─── Calculate Baby Stats ───
// Baby inherits the decayed health from the egg
// Other stats start fresh at 100 for the new life stage
// All stats reset to 100 when hatching — the baby starts fresh
const babyStats = {
hunger: DEFAULT_EGG_STATS.hunger, // Start full
happiness: DEFAULT_EGG_STATS.happiness, // Start happy
health: decayResult.stats.health, // Inherit from egg
hygiene: DEFAULT_EGG_STATS.hygiene, // Start clean
energy: DEFAULT_EGG_STATS.energy, // Start energized
hunger: STAT_MAX,
happiness: STAT_MAX,
health: STAT_MAX,
hygiene: STAT_MAX,
energy: STAT_MAX,
};
// ─── Build Updated Tags ───
@@ -6,15 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
@@ -32,6 +32,7 @@ import {
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
@@ -52,7 +53,10 @@ 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;
}
/**
@@ -70,7 +74,7 @@ export interface UseBlobbiUseInventoryItemParams {
/** Latest profile tags after migration (use instead of profile.allTags) */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
profileStorage: import('@/lib/blobbi').StorageItem[];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
@@ -186,14 +190,49 @@ export function useBlobbiUseInventoryItem({
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Validate Play Energy Requirements ───
// For play actions, validate the Blobbi has enough energy AFTER decay
if (action === 'play') {
const energyCost = Math.abs(shopItem.effect.energy ?? 0);
const currentEnergy = statsAfterDecay.energy;
if (energyCost > 0 && currentEnergy < energyCost) {
throw new Error(
`Your Blobbi needs at least ${energyCost} energy to play with this toy (current: ${currentEnergy})`
);
}
// Also check if playing would have any effect at all
// If happiness is maxed AND we can't spend energy, playing is pointless
const happinessGain = shopItem.effect.happiness ?? 0;
const currentHappiness = statsAfterDecay.happiness;
const wouldGainHappiness = happinessGain > 0 && currentHappiness < 100;
const wouldSpendEnergy = energyCost > 0 && currentEnergy >= energyCost;
if (!wouldGainHappiness && !wouldSpendEnergy) {
throw new Error(
'Playing would have no effect - your Blobbi is already at maximum happiness and has no energy to spend'
);
}
}
// ─── 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
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:
@@ -203,9 +242,15 @@ export function useBlobbiUseInventoryItem({
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++;
}
}
statsUpdate.health = currentHealth.toString();
@@ -228,11 +273,20 @@ export function useBlobbiUseInventoryItem({
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++;
}
}
statsUpdate.hygiene = currentHygiene.toString();
@@ -252,9 +306,49 @@ export function useBlobbiUseInventoryItem({
} 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++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
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++;
}
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
@@ -288,9 +382,18 @@ 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;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
...streakUpdates,
experience: newXP.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
@@ -330,15 +433,19 @@ export function useBlobbiUseInventoryItem({
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity }) => {
onSuccess: ({ itemName, action, quantity, 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.`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
@@ -14,11 +14,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import {
type DailyMissionsState,
getTodayDateString,
+1 -1
View File
@@ -15,7 +15,7 @@ import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_THEME_DEFINITION,
+5 -33
View File
@@ -5,10 +5,11 @@
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
*
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
*/
import { useQuery } from '@tanstack/react-query';
@@ -16,7 +17,7 @@ import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -38,9 +39,6 @@ export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
/** Prefix text for Blobbi hatch post */
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
/** Stat threshold for hatch dynamic task (health, hygiene, happiness >= 70) */
export const HATCH_STAT_THRESHOLD = 70;
// Legacy export for backwards compatibility
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
@@ -158,8 +156,8 @@ export const isValidBlobbiPost = isValidHatchPost;
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
* 4. Interactions - 7 total (tracked via companion.tasks cache)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 5. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
*
* @param companion - The Blobbi companion (must be incubating)
* @param interactionCount - Current interaction count from companion tasks cache
@@ -323,32 +321,6 @@ export function useHatchTasks(
// No action - just interact with Blobbi
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
// 6. Maintain Stats - health >= 70, hygiene >= 70, happiness >= 70
const stats = companion?.stats ?? {};
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const happiness = stats.happiness ?? 0;
const statsOk =
health >= HATCH_STAT_THRESHOLD &&
hygiene >= HATCH_STAT_THRESHOLD &&
happiness >= HATCH_STAT_THRESHOLD;
// Calculate minimum stat for progress display
const minStat = Math.min(health, hygiene, happiness);
tasks.push({
id: 'maintain_stats',
name: 'Keep Egg Healthy',
description: `Keep health, hygiene & happiness above ${HATCH_STAT_THRESHOLD}`,
current: statsOk ? HATCH_STAT_THRESHOLD : minStat,
required: HATCH_STAT_THRESHOLD,
completed: statsOk,
type: 'dynamic', // CRITICAL: Never persist this task
// No action - just care for your Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
+8 -10
View File
@@ -13,7 +13,6 @@ export { BlobbiPostModal } from './components/BlobbiPostModal';
export { StartIncubationDialog } from './components/StartIncubationDialog';
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
export type { AudioSource } from './components/PlayMusicModal';
// Hooks
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
@@ -61,7 +60,6 @@ export {
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
HATCH_STAT_THRESHOLD,
REQUIRED_INTERACTIONS, // Legacy export
BLOBBI_POST_PREFIX,
BLOBBI_POST_REQUIRED_HASHTAGS,
@@ -88,14 +86,14 @@ export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionPara
export { useAudioPlayback } from './hooks/useAudioPlayback';
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
// Built-in tracks
// Track catalog
export {
BLOBBI_BUILTIN_TRACKS,
getAllBuiltInTracks,
getBuiltInTrackById,
BLOBBI_TRACK_CATALOG,
getAllTracks,
getTrackById,
formatTrackDuration,
type BuiltInTrack,
} from './lib/blobbi-builtin-tracks';
type BlobbiTrack,
} from './lib/blobbi-track-catalog';
// Activity state
export {
@@ -108,11 +106,11 @@ export {
type SingActivityState,
type NoActivityState,
type BlobbiReactionState,
type MusicTrackSource,
type SelectedTrack,
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/lib/blobbi';
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
// Utilities
export {
@@ -1,6 +1,6 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/lib/blobbi';
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';
@@ -217,11 +217,6 @@ export function canUseItemForStage(
return { canUse: true };
}
// Accessories are disabled
if (shopItem.type === 'accessory') {
return { canUse: false, reason: 'Accessories are not usable yet' };
}
return { canUse: true };
}
@@ -1,6 +1,6 @@
// src/blobbi/actions/lib/blobbi-activity-state.ts
import type { AudioSource } from '../components/PlayMusicModal';
import type { SelectedTrack } from '../components/PlayMusicModal';
/**
* Types of inline activities that can be displayed in BlobbiPage
@@ -8,14 +8,14 @@ import type { AudioSource } from '../components/PlayMusicModal';
export type InlineActivityType = 'none' | 'music' | 'sing';
// Re-export for convenience
export type { AudioSource as MusicTrackSource } from '../components/PlayMusicModal';
export type { SelectedTrack } from '../components/PlayMusicModal';
/**
* State for the music inline activity
*/
export interface MusicActivityState {
type: 'music';
source: AudioSource;
selection: SelectedTrack;
isPublished: boolean;
}
@@ -54,10 +54,10 @@ export type BlobbiReactionState =
/**
* Helper to create a music activity state
*/
export function createMusicActivity(source: AudioSource): MusicActivityState {
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
return {
type: 'music',
source,
selection,
isPublished: false,
};
}
@@ -1,121 +0,0 @@
// src/blobbi/actions/lib/blobbi-builtin-tracks.ts
/**
* Built-in music tracks for the Blobbi "Play Music" action.
*
* ## Asset Location
*
* Audio files live in: `public/blobbi/audio/`
*
* In Vite, files in `public/` are served at root paths, so:
* - `public/blobbi/audio/foo.mp3` → accessible at `/blobbi/audio/foo.mp3`
*
* ## Adding New Tracks
*
* 1. Place the MP3 file in `public/blobbi/audio/`
* 2. Add a new entry to `BLOBBI_BUILTIN_TRACKS` below
* 3. Set `path` to `/blobbi/audio/<filename>.mp3`
* 4. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
*
* ## Supported Formats
*
* MP3 is recommended for maximum browser compatibility.
* WAV, OGG, and M4A may work but are browser-dependent.
*/
export interface BuiltInTrack {
/** Unique identifier for the track (used in state/events) */
id: string;
/** Display title shown in the UI */
title: string;
/** Artist or source attribution */
artist: string;
/** Path to audio file (relative to public directory root) */
path: string;
/** Duration in seconds (for display, get via ffprobe) */
durationSeconds: number;
/** Optional cover art path (relative to public directory root) */
coverArt?: string;
/** Optional tags for categorization/filtering */
tags?: string[];
}
/**
* Built-in track catalog for Blobbi music player.
*
* All tracks are royalty-free/Creative Commons licensed.
* Audio files located at: public/blobbi/audio/
*/
export const BLOBBI_BUILTIN_TRACKS: BuiltInTrack[] = [
{
id: 'nap_in_the_meadow',
title: 'Nap in the Meadow',
artist: 'Chilltape FM',
path: '/blobbi/audio/chilltapefm-nap-in-the-meadow.mp3',
durationSeconds: 240, // 4:00
tags: ['relaxing', 'nature'],
},
{
id: 'happy_kids',
title: 'Happy Kids',
artist: 'Dmitrii Kolesnikov',
path: '/blobbi/audio/happy-kids.mp3',
durationSeconds: 129, // 2:09
tags: ['upbeat', 'fun'],
},
{
id: 'soft_piano',
title: 'Soft Piano',
artist: 'Dmitrii Kolesnikov',
path: '/blobbi/audio/soft-piano.mp3',
durationSeconds: 124, // 2:04
tags: ['calming', 'sleep'],
},
{
id: 'epic_sacred_light',
title: 'Epic Sacred Light',
artist: 'Ura Megis',
path: '/blobbi/audio/epic-sacred-light.mp3',
durationSeconds: 223, // 3:43
tags: ['energetic', 'adventure'],
},
{
id: 'split_memmories',
title: 'Split Memmories',
artist: 'ido berg',
path: '/blobbi/audio/split-memmories.mp3',
durationSeconds: 153, // 2:33
tags: ['ambient', 'relaxing'],
},
{
id: 'minhas_mensagens',
title: 'Minhas Mensagens',
artist: 'PReis',
path: '/blobbi/audio/minhas-mensagens-preis.mp3',
durationSeconds: 248, // 4:08
tags: ['ambient', 'relaxing'],
},
];
/**
* Get a built-in track by ID
*/
export function getBuiltInTrackById(id: string): BuiltInTrack | undefined {
return BLOBBI_BUILTIN_TRACKS.find(track => track.id === id);
}
/**
* Get all built-in tracks
*/
export function getAllBuiltInTracks(): BuiltInTrack[] {
return BLOBBI_BUILTIN_TRACKS;
}
/**
* Format duration in seconds to MM:SS string
*/
export function formatTrackDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
+1 -1
View File
@@ -21,7 +21,7 @@ import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -0,0 +1,118 @@
// src/blobbi/actions/lib/blobbi-track-catalog.ts
/**
* Blobbi Track Catalog
*
* Music tracks for the Blobbi "Play Music" action.
* All tracks are hosted on remote Blossom servers and streamed on-demand.
*
* ## Adding New Tracks
*
* 1. Convert the audio file to M4A (AAC-LC):
* `ffmpeg -i input.m4a -c:a aac -b:a 64k -ar 48000 output.m4a`
* 2. Upload the M4A file to a Blossom server
* 3. Add a new entry to `BLOBBI_TRACK_CATALOG` below
* 4. Set `url` to the full Blossom URL
* 5. Get the duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 <file>`
*
* ## Supported Formats
*
* M4A (AAC-LC) is required for iOS/Safari compatibility and small file size.
*/
export interface BlobbiTrack {
/** Unique identifier for the track (used in state/events) */
id: string;
/** Display title shown in the UI */
title: string;
/** Artist or source attribution */
artist: string;
/** Full URL to the remote audio file (Blossom server) */
url: string;
/** Duration in seconds (for display, get via ffprobe) */
durationSeconds: number;
/** Optional cover art URL */
coverArt?: string;
/** Optional tags for categorization/filtering */
tags?: string[];
}
/**
* Blobbi track catalog.
*
* All tracks are royalty-free/Creative Commons licensed.
* Audio files hosted on remote Blossom servers.
*/
export const BLOBBI_TRACK_CATALOG: BlobbiTrack[] = [
{
id: 'nap_in_the_meadow',
title: 'Nap in the Meadow',
artist: 'Chilltape FM',
url: 'https://blossom.ditto.pub/6be1c95e879187f83af2a661ccac2bd96196f7bc334af44529ede6270b2811fc.m4a',
durationSeconds: 240, // 4:00
tags: ['relaxing', 'nature'],
},
{
id: 'happy_kids',
title: 'Happy Kids',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/94d49abd178aa8afb14737a55e0a7143f6b337f618d74858d011232bb2db845d.m4a',
durationSeconds: 129, // 2:09
tags: ['upbeat', 'fun'],
},
{
id: 'soft_piano',
title: 'Soft Piano',
artist: 'Dmitrii Kolesnikov',
url: 'https://blossom.ditto.pub/5367242d3dc555c77f5c637fd153df1166708a24c5a4c222bb4dcaeabf740743.m4a',
durationSeconds: 124, // 2:04
tags: ['calming', 'sleep'],
},
{
id: 'epic_sacred_light',
title: 'Epic Sacred Light',
artist: 'Ura Megis',
url: 'https://blossom.dreamith.to/c22953791d686605958165fd44a84cd7d9fd3d4423ebf786e47891ed3a82c6db.m4a',
durationSeconds: 223, // 3:43
tags: ['energetic', 'adventure'],
},
{
id: 'split_memories',
title: 'Split Memories',
artist: 'ido berg',
url: 'https://blossom.ditto.pub/57ba2e2122a732449880ae531d4bfac9a580bc19693c7dda735afbfa336b35fe.m4a',
durationSeconds: 153, // 2:33
tags: ['ambient', 'relaxing'],
},
{
id: 'minhas_mensagens',
title: 'Minhas Mensagens',
artist: 'PReis',
url: 'https://blossom.ditto.pub/0945064dc8f946f3392be23629b166e72090cafca7cca865a20b5395dd83ff46.m4a',
durationSeconds: 248, // 4:08
tags: ['ambient', 'relaxing'],
},
];
/**
* Get a track by ID from the catalog
*/
export function getTrackById(id: string): BlobbiTrack | undefined {
return BLOBBI_TRACK_CATALOG.find(track => track.id === id);
}
/**
* Get all tracks from the catalog
*/
export function getAllTracks(): BlobbiTrack[] {
return BLOBBI_TRACK_CATALOG;
}
/**
* Format duration in seconds to MM:SS string
*/
export function formatTrackDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
getXPGainSummary,
formatXPGain,
getXPGainMessage,
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
} from './blobbi-xp';
describe('calculateActionXP', () => {
it('returns the correct XP for each inventory action', () => {
expect(calculateActionXP('feed')).toBe(5);
expect(calculateActionXP('play')).toBe(8);
expect(calculateActionXP('clean')).toBe(6);
expect(calculateActionXP('medicine')).toBe(10);
});
it('returns the correct XP for each direct action', () => {
expect(calculateActionXP('play_music')).toBe(7);
expect(calculateActionXP('sing')).toBe(9);
});
it('returns 0 for an unknown action', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(calculateActionXP('unknown' as any)).toBe(0);
});
});
describe('calculateInventoryActionXP', () => {
it('returns base XP for quantity 1', () => {
expect(calculateInventoryActionXP('feed', 1)).toBe(5);
expect(calculateInventoryActionXP('medicine', 1)).toBe(10);
});
it('multiplies XP by quantity', () => {
expect(calculateInventoryActionXP('feed', 3)).toBe(15);
expect(calculateInventoryActionXP('play', 5)).toBe(40);
});
it('defaults to quantity 1 when not specified', () => {
expect(calculateInventoryActionXP('clean')).toBe(6);
});
it('returns 0 for quantity less than 1', () => {
expect(calculateInventoryActionXP('feed', 0)).toBe(0);
expect(calculateInventoryActionXP('feed', -1)).toBe(0);
});
});
describe('applyXPGain', () => {
it('adds XP to a current value', () => {
expect(applyXPGain(100, 25)).toBe(125);
});
it('treats undefined current XP as 0', () => {
expect(applyXPGain(undefined, 10)).toBe(10);
});
it('never returns a negative value', () => {
expect(applyXPGain(5, -20)).toBe(0);
expect(applyXPGain(0, -1)).toBe(0);
});
it('handles zero XP gain', () => {
expect(applyXPGain(50, 0)).toBe(50);
});
});
describe('getXPGainSummary', () => {
it('returns the correct xpGained and quantity', () => {
const result = getXPGainSummary('feed', 3);
expect(result).toEqual({ xpGained: 15, quantity: 3 });
});
it('defaults quantity to 1', () => {
const result = getXPGainSummary('sing');
expect(result).toEqual({ xpGained: 9, quantity: 1 });
});
});
describe('formatXPGain', () => {
it('formats positive XP as "+N XP"', () => {
expect(formatXPGain(15)).toBe('+15 XP');
expect(formatXPGain(1)).toBe('+1 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(formatXPGain(0)).toBe('');
expect(formatXPGain(-5)).toBe('');
});
});
describe('getXPGainMessage', () => {
it('formats a message with action and XP earned', () => {
expect(getXPGainMessage('feed', 5)).toBe('+5 XP earned!');
});
it('includes total when provided', () => {
expect(getXPGainMessage('feed', 5, 105)).toBe('+5 XP earned! Total: 105 XP');
});
it('returns empty string for zero or negative XP', () => {
expect(getXPGainMessage('feed', 0)).toBe('');
expect(getXPGainMessage('feed', -1)).toBe('');
});
});
describe('XP constants', () => {
it('ACTION_XP contains all inventory and direct actions', () => {
for (const action of Object.keys(INVENTORY_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
INVENTORY_ACTION_XP[action as keyof typeof INVENTORY_ACTION_XP],
);
}
for (const action of Object.keys(DIRECT_ACTION_XP)) {
expect(ACTION_XP).toHaveProperty(action);
expect(ACTION_XP[action as keyof typeof ACTION_XP]).toBe(
DIRECT_ACTION_XP[action as keyof typeof DIRECT_ACTION_XP],
);
}
});
it('all XP values are positive integers', () => {
for (const xp of Object.values(ACTION_XP)) {
expect(xp).toBeGreaterThan(0);
expect(Number.isInteger(xp)).toBe(true);
}
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* Blobbi XP (Experience Points) System
*
* This module defines XP values for all Blobbi care actions and provides
* utilities for calculating and applying XP gains.
*
* 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
* - XP accumulates across all life stages and never resets
*/
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
*/
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
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
sing: 9, // Singing requires more user effort - higher XP
};
/**
* Combined XP lookup for all action types.
* Use this for a unified XP calculation interface.
*/
export const ACTION_XP: Record<BlobbiAction, number> = {
...INVENTORY_ACTION_XP,
...DIRECT_ACTION_XP,
};
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
* Calculate XP gain for a single action.
*
* @param action - The action performed
* @returns XP points earned
*/
export function calculateActionXP(action: BlobbiAction): number {
return ACTION_XP[action] ?? 0;
}
/**
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
if (quantity < 1) return 0;
const baseXP = INVENTORY_ACTION_XP[action] ?? 0;
return baseXP * quantity;
}
/**
* Apply XP gain to current experience value.
*
* @param currentXP - Current experience points (undefined = 0)
* @param xpGain - XP points to add
* @returns New total XP (never negative)
*/
export function applyXPGain(currentXP: number | undefined, xpGain: number): number {
const current = currentXP ?? 0;
const newXP = current + xpGain;
return Math.max(0, newXP);
}
/**
* 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
*/
export function getXPGainSummary(
action: BlobbiAction,
quantity: number = 1
): { xpGained: number; quantity: number } {
const baseXP = ACTION_XP[action] ?? 0;
const xpGained = baseXP * quantity;
return { xpGained, quantity };
}
// ─── XP Display Utilities ─────────────────────────────────────────────────────
/**
* Format XP gain for display in toasts/notifications.
*
* @param xpGained - Amount of XP gained
* @returns Formatted string like "+15 XP"
*/
export function formatXPGain(xpGained: number): string {
if (xpGained <= 0) return '';
return `+${xpGained} XP`;
}
/**
* Get a descriptive message about XP gain.
*
* @param action - The action that earned XP
* @param xpGained - Amount of XP gained
* @param newTotal - New total XP (optional, for "You now have X XP" message)
* @returns Formatted message for user feedback
*/
export function getXPGainMessage(
action: BlobbiAction,
xpGained: number,
newTotal?: number
): string {
if (xpGained <= 0) return '';
const xpText = formatXPGain(xpGained);
if (newTotal !== undefined) {
return `${xpText} earned! Total: ${newTotal} XP`;
}
return `${xpText} earned!`;
}
@@ -1,58 +1,19 @@
/**
* Adult Blobbi SVG Customizer
*
*
* Handles applying colors and customizations to adult SVG content.
* Each adult form has different gradient IDs that need color mapping.
*
*
* IMPORTANT: Gradients must be preserved for 3D shading effects.
* We replace gradient colors, not the gradient structure.
*
* Uses shared utilities from blobbi/ui/lib/svg for common operations.
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { lightenColor, darkenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
import type { AdultForm, AdultSvgCustomization } from '../types/adult.types';
// ─── Color Utilities ──────────────────────────────────────────────────────────
/**
* Lighten a hex color by a percentage
*/
function lightenColor(color: string, percent: number): string {
if (color.startsWith('#')) {
const num = parseInt(color.slice(1), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = (num >> 8 & 0x00FF) + amt;
const B = (num & 0x0000FF) + amt;
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1).toUpperCase();
}
return color;
}
/**
* Darken a hex color by a percentage
*/
function darkenColor(color: string, percent: number): string {
if (color.startsWith('#')) {
const num = parseInt(color.slice(1), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) - amt;
const G = (num >> 8 & 0x00FF) - amt;
const B = (num & 0x0000FF) - amt;
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1).toUpperCase();
}
return color;
}
// ─── Gradient Builders ────────────────────────────────────────────────────────
/**
@@ -610,77 +571,6 @@ export function customizeAdultSvg(
return modifiedSvg;
}
/**
* Ensure SVG has width/height attributes so it fills its container
*/
function ensureSvgFillsContainer(svgText: string): string {
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
return svgText;
}
return svgText.replace(
/<svg([^>]*)>/,
'<svg$1 width="100%" height="100%">'
);
}
/**
* Make all SVG definition IDs unique by prefixing with an instance ID.
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
*
* Updates both:
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
* - References: url(#gradientName) → url(#prefix_gradientName)
*/
function uniquifySvgIds(svgText: string, instanceId: string): string {
// Generate a unique prefix from the full instance ID
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
// to distinguish between Blobbis owned by the same user
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
const idPattern = /\bid=["']([^"']+)["']/g;
const ids = new Set<string>();
let match;
while ((match = idPattern.exec(svgText)) !== null) {
ids.add(match[1]);
}
// Replace each ID and its references
let modified = svgText;
for (const id of ids) {
const prefixedId = `${prefix}_${id}`;
// Replace the ID definition
modified = modified.replace(
new RegExp(`\\bid=["']${id}["']`, 'g'),
`id="${prefixedId}"`
);
// Replace url() references
modified = modified.replace(
new RegExp(`url\\(#${id}\\)`, 'g'),
`url(#${prefixedId})`
);
// Replace xlink:href references (older SVG format)
modified = modified.replace(
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
`xlink:href="#${prefixedId}"`
);
// Replace href references (newer SVG format)
modified = modified.replace(
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
`href="#${prefixedId}"`
);
}
return modified;
}
/**
* Fallback: Apply generic body gradient for forms without specific customizer
*/
@@ -5,7 +5,7 @@
* Each adult form has its own folder with base and sleeping variants.
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import {
type AdultForm,
type AdultSvgResolverOptions,
+1 -1
View File
@@ -4,7 +4,7 @@
* Type definitions for adult stage visuals and customization
*/
import type { Blobbi } from '@/types/blobbi';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
/**
* All available adult evolution forms.
@@ -1,35 +1,14 @@
/**
* Baby Blobbi SVG Customizer
*
* Handles applying colors and customizations to baby SVG content
*
* Handles applying colors and customizations to baby SVG content.
* Uses shared utilities from blobbi/ui/lib/svg for common operations.
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
import { lightenColor, uniquifySvgIds, ensureSvgFillsContainer } from '@/blobbi/ui/lib/svg';
import { BabySvgCustomization } from '../types/baby.types';
/**
* Lighten a color by a percentage
*/
function lightenColor(color: string, percent: number): string {
// Handle hex colors
if (color.startsWith('#')) {
const num = parseInt(color.slice(1), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = (num >> 8 & 0x00FF) + amt;
const B = (num & 0x0000FF) + amt;
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1).toUpperCase();
}
// Return as-is for non-hex colors (rgb, etc.)
return color;
}
/**
* Apply color customizations to baby SVG
*
@@ -78,79 +57,6 @@ export function customizeBabySvg(
return modifiedSvg;
}
/**
* Ensure SVG has width/height attributes so it fills its container
*/
function ensureSvgFillsContainer(svgText: string): string {
// Check if width and height are already set
if (/\swidth=/.test(svgText) && /\sheight=/.test(svgText)) {
return svgText;
}
// Add width="100%" height="100%" to the SVG tag
return svgText.replace(
/<svg([^>]*)>/,
'<svg$1 width="100%" height="100%">'
);
}
/**
* Make all SVG definition IDs unique by prefixing with an instance ID.
* This prevents gradient ID collisions when multiple Blobbis are rendered on the same page.
*
* Updates both:
* - Definition IDs: id="gradientName" → id="prefix_gradientName"
* - References: url(#gradientName) → url(#prefix_gradientName)
*/
function uniquifySvgIds(svgText: string, instanceId: string): string {
// Generate a unique prefix from the full instance ID
// Sanitize to only allow valid SVG ID characters (letters, numbers, underscore, hyphen)
// Note: instanceId format is "blobbi-{pubkeyPrefix12}-{petId10}" so we need the full ID
// to distinguish between Blobbis owned by the same user
const prefix = `b_${instanceId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
// Find all IDs defined in the SVG (in defs, gradients, clipPaths, etc.)
const idPattern = /\bid=["']([^"']+)["']/g;
const ids = new Set<string>();
let match;
while ((match = idPattern.exec(svgText)) !== null) {
ids.add(match[1]);
}
// Replace each ID and its references
let modified = svgText;
for (const id of ids) {
const prefixedId = `${prefix}_${id}`;
// Replace the ID definition
modified = modified.replace(
new RegExp(`\\bid=["']${id}["']`, 'g'),
`id="${prefixedId}"`
);
// Replace url() references
modified = modified.replace(
new RegExp(`url\\(#${id}\\)`, 'g'),
`url(#${prefixedId})`
);
// Replace xlink:href references (older SVG format)
modified = modified.replace(
new RegExp(`xlink:href=["']#${id}["']`, 'g'),
`xlink:href="#${prefixedId}"`
);
// Replace href references (newer SVG format)
modified = modified.replace(
new RegExp(`\\bhref=["']#${id}["']`, 'g'),
`href="#${prefixedId}"`
);
}
return modified;
}
/**
* Apply body gradient customization
*/
@@ -4,7 +4,7 @@
* Handles loading and resolving baby stage SVG assets
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
import { BabyVariant, BabySvgResolverOptions } from '../types/baby.types';
import { BABY_BASE_SVG, BABY_SLEEPING_SVG } from './baby-svg-data';
+1 -1
View File
@@ -4,7 +4,7 @@
* Type definitions for baby stage visuals and customization
*/
import { Blobbi } from '@/types/blobbi';
import { Blobbi } from '@/blobbi/core/types/blobbi';
/**
* Baby visual variant types
@@ -19,6 +19,7 @@ import type {
Position,
EntryState,
} from '../types/companion.types';
import type { RefObject } from 'react';
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import {
calculateFloatAnimation,
@@ -28,6 +29,9 @@ import {
} from '../utils/animation';
import { BlobbiCompanionVisual } from './BlobbiCompanionVisual';
import { useClickDetection } from '../interaction';
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';
interface BlobbiCompanionProps {
/** Companion data */
@@ -36,8 +40,8 @@ interface BlobbiCompanionProps {
state: CompanionState;
/** Current motion state */
motion: CompanionMotion;
/** Eye offset for gaze */
eyeOffset: EyeOffset;
/** Ref-based eye offset for imperative gaze control (avoids per-frame rerenders) */
eyeOffsetRef: RefObject<EyeOffset>;
/** Whether entry animation is playing */
isEntering: boolean;
/** Entry animation progress (0-1) */
@@ -58,6 +62,17 @@ interface BlobbiCompanionProps {
onEndDrag: () => void;
/** Click callback (when interaction is a click, not a drag) */
onClick?: () => void;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (CSS class names). */
recipeLabel?: string;
/** Named emotion preset (convenience). Ignored when `recipe` is provided. */
emotion?: BlobbiEmotion;
/**
* Body-level visual effects — for manual/external use only.
* Status-reaction body effects are already folded into the recipe.
*/
bodyEffects?: BodyEffectsSpec;
/** Callback to report rendered position (including animations) */
onPositionUpdate?: (position: Position) => void;
/** Debug mode - disables animations and shows visual debug aids */
@@ -68,7 +83,7 @@ export function BlobbiCompanion({
companion,
state,
motion,
eyeOffset,
eyeOffsetRef,
isEntering,
entryProgress: _entryProgress,
entryState,
@@ -79,13 +94,17 @@ export function BlobbiCompanion({
onUpdateDrag,
onEndDrag,
onClick,
recipe,
recipeLabel,
emotion,
bodyEffects,
onPositionUpdate,
debugMode = false,
}: BlobbiCompanionProps) {
const config = DEFAULT_COMPANION_CONFIG;
const containerRef = useRef<HTMLDivElement>(null);
const [animationTime, setAnimationTime] = useState(0);
// Click detection - distinguishes click from drag
const clickDetection = useClickDetection({
onClick,
@@ -174,8 +193,9 @@ export function BlobbiCompanion({
}
// Calculate floating animation offset (gentle sway/float)
// Skip during entry animation, dragging, or debug mode
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode)
// Skip during entry animation, dragging, debug mode, or sleeping
const isSleeping = companion.state === 'sleeping';
const floatOffset = (!useEntryPosition && !motion.isDragging && !debugMode && !isSleeping)
? calculateFloatAnimation(animationTime, state === 'walking')
: { x: 0, y: 0, rotation: 0 };
@@ -209,12 +229,15 @@ export function BlobbiCompanion({
: undefined;
// Drag handlers with click detection
// Uses pointer events only (handles mouse, touch, and pen natively)
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Capture pointer for tracking outside element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Capture pointer on the container (not e.target which may be a child)
// for reliable tracking across element boundaries during drag
if (containerRef.current) {
containerRef.current.setPointerCapture(e.pointerId);
}
// Start click detection tracking
clickDetection.handlePointerDown({ x: e.clientX, y: e.clientY });
@@ -235,7 +258,9 @@ export function BlobbiCompanion({
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
if (containerRef.current) {
containerRef.current.releasePointerCapture(e.pointerId);
}
// Finalize click detection - will call onClick if it was a click
clickDetection.handlePointerUp();
@@ -246,42 +271,6 @@ export function BlobbiCompanion({
}
}, [clickDetection, motion.isDragging, onEndDrag]);
// Touch handlers for mobile (with click detection)
const handleTouchStart = useCallback((e: React.TouchEvent) => {
e.preventDefault();
if (e.touches.length === 0) return;
const touch = e.touches[0];
clickDetection.handlePointerDown({ x: touch.clientX, y: touch.clientY });
}, [clickDetection]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 0) return;
const touch = e.touches[0];
const position = { x: touch.clientX, y: touch.clientY };
// Check if movement exceeds click threshold (starts drag)
const isDrag = clickDetection.handlePointerMove(position);
// If dragging, update position
if (motion.isDragging || isDrag) {
const newX = touch.clientX - config.size / 2;
const newY = touch.clientY - config.size / 2;
onUpdateDrag({ x: newX, y: newY });
}
}, [clickDetection, motion.isDragging, config.size, onUpdateDrag]);
const handleTouchEnd = useCallback(() => {
// Finalize click detection
clickDetection.handlePointerUp();
// Always end drag state
if (motion.isDragging) {
onEndDrag();
}
}, [clickDetection, motion.isDragging, onEndDrag]);
return (
<div
ref={containerRef}
@@ -302,20 +291,21 @@ export function BlobbiCompanion({
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<BlobbiCompanionVisual
companion={companion}
size={config.size}
eyeOffset={eyeOffset}
eyeOffsetRef={eyeOffsetRef}
direction={isEntering ? 'right' : motion.direction}
isDragging={motion.isDragging}
isWalking={state === 'walking'}
floatOffset={floatOffset}
isOnGround={isOnGround}
distanceFromGround={distanceFromGround}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
debugMode={debugMode}
/>
</div>
@@ -1,26 +1,31 @@
/**
* BlobbiCompanionLayer
*
* Global layer component that renders the companion above all other content.
* This should be placed at the root level of the app.
*
* Entry animations are VERTICAL based on sidebar navigation direction:
* - Navigating DOWN the sidebar: Blobbi falls from the top of the screen
* - Navigating UP the sidebar: Blobbi rises from the bottom with inspection
*
* Interaction features:
* - Click/tap on Blobbi opens action menu
* - Action menu shows available actions in a radial layout
* - Selecting an action shows available items as floating bubbles
* BlobbiCompanionLayer — Global orchestration layer for the companion.
*
* This component is the top-level coordinator. It is NOT a visual component.
* It wires together:
* - Companion runtime (position, motion, gaze, entry animations)
* - Status reaction system (stats → visual recipe)
* - Action menu and hanging items interaction
* - Item use with temporary emotion overrides
*
* Visual rendering is delegated entirely to:
* BlobbiCompanion BlobbiCompanionVisual → MemoizedBlobbiVisual → Visual → SvgRenderer
*
* This file should be placed at the app root level (renders a fixed overlay).
*/
import { useCallback, useState } from 'react';
import { useCallback, useState, useMemo } from 'react';
import { useBlobbiCompanion } from '../hooks/useBlobbiCompanion';
import { useCompanionItemReaction } from '../hooks/useCompanionItemReaction';
import { useActionEmotionOverride } from '../hooks/useActionEmotionOverride';
import { BlobbiCompanion } from './BlobbiCompanion';
import { DebugGroundOverlay } from './DebugGroundOverlay';
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import { calculateGroundY } from '../utils/movement';
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
import type { ActionType } from '@/blobbi/ui/lib/status-reactions';
import {
useCompanionActionMenu,
useBlobbiActions,
@@ -30,30 +35,19 @@ import {
type CompanionItem,
type ItemLandedData,
} from '../interaction';
import { useBlobbiSleepToggle } from '../interaction/useBlobbiSleepToggle';
import type { Position } from '../types/companion.types';
// DEBUG MODE - Set to true to debug ground contact
/** Set to true to show debug ground-contact lines. */
const DEBUG_GROUND_CONTACT = false;
/**
* Global companion layer.
*
* Renders the companion if:
* - User is logged in
* - User has set a current_companion in their profile
* - The companion data is loaded
*
* Entry animations are vertical:
* - Falls from top when navigating DOWN the sidebar
* - Rises from bottom (with inspection) when navigating UP the sidebar
*/
export function BlobbiCompanionLayer() {
const {
companion,
isVisible,
state,
motion,
eyeOffset,
eyeOffsetRef,
isEntering,
entryProgress,
entryState,
@@ -65,19 +59,20 @@ export function BlobbiCompanionLayer() {
endDrag,
triggerAttention,
} = useBlobbiCompanion();
const config = DEFAULT_COMPANION_CONFIG;
// Track the actual rendered position of the companion
// This accounts for entry animations, float offset, etc.
// ── Rendered position tracking ─────────────────────────────────────────────
// Tracks the actual visual position (including entry/float offsets) so
// the action menu and hanging items can position relative to Blobbi.
const [renderedPosition, setRenderedPosition] = useState<Position>(motion.position);
// Handle position updates from BlobbiCompanion
const handlePositionUpdate = useCallback((position: Position) => {
setRenderedPosition(position);
}, []);
// Callback for glancing at items (when Blobbi doesn't need them)
// ── Item reaction ──────────────────────────────────────────────────────────
const handleGlanceAtItem = useCallback((position: Position) => {
triggerAttention(position, {
duration: 800,
@@ -86,39 +81,31 @@ export function BlobbiCompanionLayer() {
isGlance: true,
});
}, [triggerAttention]);
// Callback for walking to items (when Blobbi needs them)
// For now, we just glance more intensely - full walking behavior
// would require deeper integration with the state machine
const handleWalkToItem = useCallback((position: Position) => {
// TODO: Implement actual walking behavior via useBlobbiCompanionState
// For now, trigger a longer attention to simulate interest
triggerAttention(position, {
duration: 1500,
priority: 'normal',
source: 'item-landed:need',
isGlance: false, // Use longer cooldown for "interested" attention
isGlance: false,
});
}, [triggerAttention]);
// Item reaction hook - determines if Blobbi needs items and how to react
const { reactToItemLanding } = useCompanionItemReaction({
isActive: isVisible && !isEntering,
onGlance: handleGlanceAtItem,
onWalkTo: handleWalkToItem,
});
// Handle when an item finishes falling and lands on the ground
const handleItemLanded = useCallback((data: ItemLandedData) => {
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Item landed:', data.item.name, 'at', { x: data.x, y: data.y });
}
// React to the item landing based on Blobbi's needs
reactToItemLanding(data.item.category, { x: data.x, y: data.y });
}, [reactToItemLanding]);
// Action menu state
// ── Action menu ────────────────────────────────────────────────────────────
const {
menuState,
availableActions,
@@ -130,57 +117,56 @@ export function BlobbiCompanionLayer() {
isActive: isVisible,
stage: companion?.stage,
onItemClick: (item) => {
// Item was clicked in the hanging menu - this releases it
console.log('[CompanionLayer] Item released:', item);
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Item released:', item);
}
},
});
// Get Blobbi actions from context
// This now works even when BlobbiPage is not mounted (uses built-in fallback)
const {
useItem: contextUseItem,
canUseItems,
isItemOnCooldown
const {
useItem: contextUseItem,
canUseItems,
isItemOnCooldown,
} = useBlobbiActions();
/**
* Handle item use - called when item contacts Blobbi or is clicked.
* Uses the BlobbiActionsContext to perform the actual item use.
* Returns success/failure to control whether item is removed from screen.
*
* Now works from any page (not just /blobbi) thanks to the built-in
* fallback in BlobbiActionsContext.
*/
// Standalone sleep/wake toggle — works without BlobbiPage mounted
const { toggleSleep } = useBlobbiSleepToggle();
// ── Item use with emotion override ─────────────────────────────────────────
const { actionOverride, triggerOverride } = useActionEmotionOverride();
const handleItemUse = useCallback(async (item: CompanionItem): Promise<{ success: boolean; error?: string }> => {
// Resolve the action from the item category
const action = CATEGORY_TO_ACTION[item.category];
if (!action) {
if (import.meta.env.DEV) {
console.warn('[CompanionLayer] No action for item category:', item.category);
}
return { success: false, error: `Cannot use ${item.category} items` };
}
if (!canUseItems) {
if (import.meta.env.DEV) {
console.warn('[CompanionLayer] Cannot use items - no companion selected');
}
return { success: false, error: 'No companion selected' };
}
// Trigger the temporary emotion override for visual feedback
triggerOverride(action as ActionType);
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Using item:', item.name, 'with action:', action);
}
try {
const result = await contextUseItem(item.id, action, 1);
if (result.success) {
if (import.meta.env.DEV) {
console.log('[CompanionLayer] Item used successfully:', item.name, result.statsChanged);
}
// Close the menu after successful use
closeMenu();
return { success: true };
} else {
@@ -196,154 +182,130 @@ export function BlobbiCompanionLayer() {
}
return { success: false, error: errorMessage };
}
}, [canUseItems, contextUseItem, closeMenu]);
// Handle companion click
}, [canUseItems, contextUseItem, closeMenu, triggerOverride]);
// ── Companion click ────────────────────────────────────────────────────────
// ── Sleep action (direct, not item-based) ───────────────────────────────────
const handleSleepAction = useCallback(async () => {
closeMenu();
try {
await toggleSleep();
} catch (error) {
if (import.meta.env.DEV) {
console.error('[CompanionLayer] Sleep toggle failed:', error);
}
}
}, [toggleSleep, closeMenu]);
/** Intercept action selection: sleep is a direct action, others go through item flow. */
const handleActionClick = useCallback((action: Parameters<typeof selectAction>[0]) => {
if (action === 'sleep') {
handleSleepAction();
} else {
selectAction(action);
}
}, [handleSleepAction, selectAction]);
const handleCompanionClick = useCallback(() => {
// Don't open menu during entry animation
if (isEntering) return;
toggleMenu();
}, [isEntering, toggleMenu]);
// Handle click outside menu
const handleClickOutside = useCallback(() => {
closeMenu();
}, [closeMenu]);
// Don't render anything if not visible
// ── Status reaction ────────────────────────────────────────────────────────
// Resolves companion stats into a visual recipe (sleepy, hungry, dirty, etc.).
// The actionOverride from useActionEmotionOverride temporarily overrides
// the recipe when an item is used (e.g., feeding → happy face for 1.5s).
//
// Status reaction stays ENABLED during sleep so body effects (dirty) and
// extras (food icon) still resolve. The sleeping recipe overlay is applied
// on top to override the face while preserving compatible body effects.
const isSleeping = companion?.state === 'sleeping';
const companionStats = useMemo(() => companion?.stats ?? {
hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100,
}, [companion?.stats]);
const { recipe: statusRecipe, recipeLabel: statusRecipeLabel } = useStatusReaction({
stats: companionStats,
enabled: isVisible && companion?.stage !== 'egg',
actionOverride: isSleeping ? null : actionOverride,
});
// When sleeping, overlay the sleeping face on top of the status recipe.
// This keeps body effects (dirty, stink) and food icon while overriding
// eyes, mouth, and eyebrows with sleeping visuals.
const companionRecipe = isSleeping
? buildSleepingRecipe(statusRecipe)
: statusRecipe;
const companionRecipeLabel = isSleeping ? 'sleeping' : statusRecipeLabel;
// ── Early return ───────────────────────────────────────────────────────────
if (!isVisible || !companion) {
return null;
}
// Companion props
const companionProps = {
companion,
state,
motion,
eyeOffset,
isEntering,
entryProgress,
entryState,
wasResolvedFromStuck,
groundPosition,
viewport,
onStartDrag: startDrag,
onUpdateDrag: updateDrag,
onEndDrag: endDrag,
onClick: handleCompanionClick,
onPositionUpdate: handlePositionUpdate,
};
// Calculate ground position for debug line
// ── Render ─────────────────────────────────────────────────────────────────
const debugGroundY = calculateGroundY(viewport.height, config.size, config);
return (
<div
<div
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 9999 }}
aria-hidden="true"
>
{/* DEBUG: Visible ground line */}
{DEBUG_GROUND_CONTACT && (
<>
{/* Ground line where Blobbi's CONTAINER bottom should be */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: debugGroundY + config.size, // Container bottom
height: 2,
backgroundColor: 'red',
zIndex: 10002,
}}
/>
{/* Label for the ground line */}
<div
style={{
position: 'fixed',
right: 10,
top: debugGroundY + config.size + 4,
color: 'red',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Container bottom (groundY + size = {Math.round(debugGroundY + config.size)}px)
</div>
{/* Another line showing the actual viewport bottom minus padding */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: viewport.height - config.padding.bottom,
height: 2,
backgroundColor: 'blue',
zIndex: 10002,
}}
/>
<div
style={{
position: 'fixed',
right: 10,
top: viewport.height - config.padding.bottom + 4,
color: 'blue',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Viewport - padding = {viewport.height - config.padding.bottom}px (Target ground)
</div>
{/* Entry type indicator */}
{isEntering && (
<div
style={{
position: 'fixed',
left: 10,
top: 10,
color: entryState.entryType === 'fall' ? 'orange' : 'green',
fontSize: 14,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '4px 8px',
borderRadius: 4,
}}
>
Entry: {entryState.entryType.toUpperCase()} | Phase: {entryState.phase}
</div>
)}
</>
<DebugGroundOverlay
groundY={debugGroundY}
size={config.size}
viewportHeight={viewport.height}
paddingBottom={config.padding.bottom}
isEntering={isEntering}
entryState={entryState}
/>
)}
{/* Companion */}
<div className="pointer-events-auto">
<BlobbiCompanion
{...companionProps}
<BlobbiCompanion
companion={companion}
state={state}
motion={motion}
eyeOffsetRef={eyeOffsetRef}
isEntering={isEntering}
entryProgress={entryProgress}
entryState={entryState}
wasResolvedFromStuck={wasResolvedFromStuck}
groundPosition={groundPosition}
viewport={viewport}
onStartDrag={startDrag}
onUpdateDrag={updateDrag}
onEndDrag={endDrag}
onClick={handleCompanionClick}
recipe={companionRecipe}
recipeLabel={companionRecipeLabel}
onPositionUpdate={handlePositionUpdate}
debugMode={DEBUG_GROUND_CONTACT}
/>
</div>
{/* Action Menu - radial buttons around Blobbi */}
<CompanionActionMenu
isOpen={menuState.isOpen}
companionPosition={renderedPosition}
companionSize={config.size}
actions={availableActions}
selectedAction={menuState.selectedAction}
onActionClick={selectAction}
onActionClick={handleActionClick}
onClickOutside={handleClickOutside}
isSleeping={isSleeping}
/>
{/* Hanging Items - items displayed as hanging elements from top */}
<HangingItems
isVisible={menuState.isOpen && menuState.selectedAction !== null}
selectedAction={menuState.selectedAction}
@@ -1,197 +1,210 @@
/**
* BlobbiCompanionVisual
*
*
* Visual component for rendering the companion Blobbi.
* Supports external eye offset control for custom gaze behavior.
*
* Architecture:
* - Outer shell: handles per-frame updates (float, shadow, drag state) — rerenders freely
* - Float wrapper: owns translateY alignment + JS float offset (inline transform)
* - Sway wrapper: owns CSS rotation animation only (animate-blobbi-sway)
* Kept separate from float wrapper so CSS @keyframes don't override the
* inline translateY, which would make Blobbi float above the ground.
* - Inner MemoizedBlobbiVisual: renders the actual SVG — only rerenders when visual inputs change
* - Eye gaze is driven imperatively via ref (no React rerenders for gaze)
*/
import { useMemo, useRef } from 'react';
import { useMemo, memo, type RefObject } from 'react';
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
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';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { cn } from '@/lib/utils';
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
import type { Blobbi } from '@/types/blobbi';
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiCompanionVisualProps {
/** Companion data */
companion: CompanionData;
/** Size in pixels */
size: number;
/** Eye offset for gaze direction */
eyeOffset: EyeOffset;
/** Facing direction (used for gaze, not for flipping) */
eyeOffsetRef: RefObject<EyeOffset>;
direction: CompanionDirection;
/** Whether the companion is being dragged */
isDragging: boolean;
/** Whether the companion is walking */
isWalking: boolean;
/** Floating animation offset for gentle sway */
floatOffset?: { x: number; y: number; rotation: number };
/** Whether Blobbi is on or near the ground (affects shadow visibility) */
isOnGround?: boolean;
/** Distance from ground in pixels (for shadow fade, 0 = on ground) */
distanceFromGround?: number;
/** Additional class names */
recipe?: BlobbiVisualRecipe;
recipeLabel?: string;
emotion?: BlobbiEmotion;
bodyEffects?: BodyEffectsSpec;
className?: string;
/** Debug mode - shows visual boundaries */
debugMode?: boolean;
}
/**
* Convert CompanionData to the Blobbi type for rendering.
*/
function toBlobiForVisual(companion: CompanionData): Blobbi {
return {
id: companion.d,
name: companion.name,
lifeStage: companion.stage,
state: 'active',
isSleeping: false,
stats: {
hunger: 100,
happiness: 100,
health: 100,
hygiene: 100,
energy: companion.energy,
},
baseColor: companion.visualTraits.baseColor,
secondaryColor: companion.visualTraits.secondaryColor,
eyeColor: companion.visualTraits.eyeColor,
pattern: companion.visualTraits.pattern,
specialMark: companion.visualTraits.specialMark,
size: companion.visualTraits.size,
seed: companion.seed ?? '',
tags: [],
// Include adult form info for proper rendering
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
};
// ─── Memoized Inner Visual ────────────────────────────────────────────────────
//
// STABILITY CONTRACT:
// This component is the boundary that protects the SVG DOM subtree from the
// companion rerender storm (~60 renders/s from motion/float RAF loops).
// It renders BlobbiAdultVisual / BlobbiBabyVisual with renderMode="companion".
//
// It MUST only rerender when actual visual content changes:
// blobbi, recipe, recipeLabel, emotion, bodyEffects, stage
//
// It MUST NOT receive or depend on per-frame values:
// eyeOffset value, floatOffset, isDragging, isWalking, position, animationTime
//
// The eyeOffsetRef is a stable React ref — its identity never changes,
// so it is safe to pass without triggering rerenders.
interface MemoizedBlobbiVisualProps {
stage: 'baby' | 'adult';
blobbi: Blobbi;
eyeOffsetRef: RefObject<EyeOffset>;
recipe?: BlobbiVisualRecipe;
recipeLabel?: string;
emotion: BlobbiEmotion;
bodyEffects?: BodyEffectsSpec;
}
const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({
stage,
blobbi,
eyeOffsetRef,
recipe,
recipeLabel,
emotion,
bodyEffects,
}: MemoizedBlobbiVisualProps) {
if (stage === 'baby') {
return (
<BlobbiBabyVisual
blobbi={blobbi}
renderMode="companion"
lookMode="forward"
externalEyeOffsetRef={eyeOffsetRef}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
);
}
return (
<BlobbiAdultVisual
blobbi={blobbi}
renderMode="companion"
lookMode="forward"
externalEyeOffsetRef={eyeOffsetRef}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
);
}, (prev, next) => {
return (
prev.stage === next.stage &&
prev.blobbi === next.blobbi &&
prev.recipe === next.recipe &&
prev.recipeLabel === next.recipeLabel &&
prev.emotion === next.emotion &&
prev.bodyEffects === next.bodyEffects
);
});
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiCompanionVisual({
companion,
size,
eyeOffset,
eyeOffsetRef,
direction,
isDragging,
isWalking,
floatOffset = { x: 0, y: 0, rotation: 0 },
isOnGround = true,
distanceFromGround = 0,
recipe: recipeProp,
recipeLabel: recipeLabelProp,
emotion: emotionProp,
bodyEffects: bodyEffectsProp,
className,
debugMode = false,
}: BlobbiCompanionVisualProps) {
const containerRef = useRef<HTMLDivElement>(null);
const blobbi = useMemo(() => toBlobiForVisual(companion), [companion]);
// DEV ONLY: Get effective emotion from dev context
const effectiveEmotion = useEffectiveEmotion();
// Eye offset is now passed directly to the visual components via externalEyeOffset prop
// This is more reliable than DOM manipulation which can be overwritten by useBlobbiEyes
// Build transform for floating animation
// No flipping based on direction - Blobbi always faces the same way
const blobbi = useMemo(() => companionDataToBlobbi(companion), [companion]);
// DEV ONLY: Get effective emotion from dev context (overrides production emotions)
const devEmotion = useEffectiveEmotion();
const hasDevOverride = devEmotion !== 'neutral';
const effectiveRecipe = hasDevOverride ? undefined : recipeProp;
const effectiveRecipeLabel = hasDevOverride ? undefined : recipeLabelProp;
const effectiveEmotion = hasDevOverride ? devEmotion : (emotionProp ?? 'neutral');
const effectiveBodyEffects = hasDevOverride ? undefined : bodyEffectsProp;
// Float transform
const blobbiTransform = useMemo(() => {
const transforms: string[] = [];
if (floatOffset.x !== 0 || floatOffset.y !== 0) {
transforms.push(`translate(${floatOffset.x}px, ${floatOffset.y}px)`);
}
if (floatOffset.rotation !== 0) {
transforms.push(`rotate(${floatOffset.rotation}deg)`);
}
return transforms.length > 0 ? transforms.join(' ') : undefined;
}, [floatOffset]);
// Determine reaction state
const reaction = isDragging ? 'happy' : isWalking ? 'idle' : 'idle';
// Shadow visibility and appearance based on ground proximity
// Shadow should only appear when Blobbi is on or very near the ground
const SHADOW_FADE_DISTANCE = 30; // Shadow fully fades at this distance from ground
// Reaction state for CSS animations on the OUTER wrapper
// When sleeping, always idle — no swaying/happy animation
const isSleeping = companion.state === 'sleeping';
const reaction = isSleeping ? 'idle' : isDragging ? 'happy' : isWalking ? 'swaying' : 'idle';
// ── Shadow ─────────────────────────────────────────────────────────────────
const SHADOW_FADE_DISTANCE = 30;
const SHADOW_MAX_OPACITY = 0.35;
// Calculate shadow visibility based on actual ground distance, not just float offset
const showShadow = isOnGround && !isDragging && distanceFromGround < SHADOW_FADE_DISTANCE;
// Shadow fades as Blobbi gets farther from ground
// Also factor in the float animation offset for subtle breathing effect
const floatHeight = Math.abs(floatOffset.y);
const groundFadeRatio = Math.max(0, 1 - distanceFromGround / SHADOW_FADE_DISTANCE);
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02); // Subtle fade during float
const floatFadeRatio = Math.max(0.85, 1 - floatHeight * 0.02);
const shadowOpacity = SHADOW_MAX_OPACITY * groundFadeRatio * floatFadeRatio;
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio; // Slightly smaller when lifting
// Suppress unused variable warning for direction (kept for API compatibility)
const shadowScale = 0.9 + 0.1 * groundFadeRatio * floatFadeRatio;
// direction is accepted for API completeness but not currently used for rendering
// (Blobbi does not flip based on facing direction). Suppress unused warning.
void direction;
return (
<div
ref={containerRef}
<div
className={cn('relative', className)}
style={{ width: size, height: size }}
>
{/* DEBUG: Container and alignment markers */}
{/* Debug alignment markers */}
{debugMode && (
<>
{/* Container outline - lime */}
<div
className="absolute inset-0 pointer-events-none"
style={{
border: '2px solid lime',
boxSizing: 'border-box',
}}
/>
{/* 88% line from top (where SVG body bottom should be before shift) - yellow */}
<div
className="absolute pointer-events-none"
style={{
top: `${size * 0.88}px`,
left: 0,
right: 0,
height: 2,
backgroundColor: 'yellow',
}}
/>
{/* 100% line (container bottom where body should touch after shift) - cyan */}
<div
className="absolute pointer-events-none"
style={{
bottom: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: 'cyan',
}}
/>
{/* Label showing the expected shift */}
<div
className="absolute pointer-events-none"
style={{
top: 2,
left: 2,
fontSize: 8,
color: 'white',
backgroundColor: 'black',
padding: '1px 2px',
}}
>
<div className="absolute inset-0 pointer-events-none" style={{ border: '2px solid lime', boxSizing: 'border-box' }} />
<div className="absolute pointer-events-none" style={{ top: `${size * 0.88}px`, left: 0, right: 0, height: 2, backgroundColor: 'yellow' }} />
<div className="absolute pointer-events-none" style={{ bottom: 0, left: 0, right: 0, height: 2, backgroundColor: 'cyan' }} />
<div className="absolute pointer-events-none" style={{ top: 2, left: 2, fontSize: 8, color: 'white', backgroundColor: 'black', padding: '1px 2px' }}>
shift: {size * 0.12}px
</div>
</>
)}
{/* Floor shadow - only visible when Blobbi is on/near the ground */}
{/* Hidden during: dragging, entry animations, falling, or when far from ground */}
{/* Floor shadow */}
{!debugMode && showShadow && shadowOpacity > 0.01 && (
<div
className="absolute pointer-events-none"
style={{
// Position shadow well below Blobbi to feel like it's on the floor
bottom: -20,
left: '50%',
width: size * 0.5,
@@ -200,53 +213,53 @@ export function BlobbiCompanionVisual({
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowOpacity}) 0%, rgba(0,0,0,${shadowOpacity * 0.5}) 40%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(4px)',
opacity: groundFadeRatio, // Additional opacity control for smooth fade
opacity: groundFadeRatio,
transition: 'opacity 0.15s ease-out, transform 0.1s ease-out',
}}
/>
)}
{/* Blobbi visual with floating transform */}
{/*
The Blobbi SVG has empty space: 15% at top (body starts at y=15), 12% at bottom (body ends at y=88).
To align the visible body bottom with the container bottom, we shift down by 12% of container size.
This is applied BEFORE the float transform so the ground position is correct.
{/*
Float wrapper — owns translateY alignment + JS float offset.
This is a separate element from the sway wrapper below so that
the CSS animation on the sway wrapper does not override the
inline transform here. (CSS @keyframes replace the entire
`transform` property while active, which would drop the
translateY alignment shift and cause Blobbi to float above
the ground during walking.)
*/}
<div
className="size-full"
style={{
// First apply the SVG alignment correction, then the float animation
// The 12% shift pushes the SVG down so its visible body bottom aligns with container bottom
transform: [
`translateY(${size * 0.12}px)`, // SVG body alignment correction
blobbiTransform, // Float animation (if any)
`translateY(${size * 0.12}px)`,
blobbiTransform,
].filter(Boolean).join(' ') || undefined,
transformOrigin: 'center bottom',
transition: isDragging ? 'none' : 'transform 0.05s ease-out',
// DEBUG: Show the shifted wrapper
...(debugMode ? { outline: '2px dashed magenta' } : {}),
}}
>
{companion.stage === 'baby' && (
<BlobbiBabyVisual
blobbi={blobbi}
reaction={reaction}
lookMode="forward"
externalEyeOffset={eyeOffset}
emotion={effectiveEmotion}
className="size-full"
/>
)}
{companion.stage === 'adult' && (
<BlobbiAdultVisual
blobbi={blobbi}
reaction={reaction}
lookMode="forward"
externalEyeOffset={eyeOffset}
emotion={effectiveEmotion}
className="size-full"
/>
)}
{/* Sway wrapper — CSS rotation only, no positioning transforms */}
<div
className={cn(
'size-full',
(reaction === 'swaying' || reaction === 'happy') && 'animate-blobbi-sway',
)}
style={{ transformOrigin: 'center bottom' }}
>
{(companion.stage === 'baby' || companion.stage === 'adult') && (
<MemoizedBlobbiVisual
stage={companion.stage}
blobbi={blobbi}
eyeOffsetRef={eyeOffsetRef}
recipe={effectiveRecipe}
recipeLabel={effectiveRecipeLabel}
emotion={effectiveEmotion}
bodyEffects={effectiveBodyEffects}
/>
)}
</div>
</div>
</div>
);
@@ -0,0 +1,108 @@
/**
* DebugGroundOverlay — Debug-only visual overlay for ground contact debugging.
*
* Shows horizontal lines indicating:
* - Container bottom (where Blobbi's container ends)
* - Viewport bottom minus padding (target ground position)
* - Entry animation type and phase (during entry)
*
* Enabled by setting DEBUG_GROUND_CONTACT = true in BlobbiCompanionLayer.
*/
import type { EntryState } from '../types/companion.types';
interface DebugGroundOverlayProps {
groundY: number;
size: number;
viewportHeight: number;
paddingBottom: number;
isEntering: boolean;
entryState: EntryState;
}
export function DebugGroundOverlay({
groundY,
size,
viewportHeight,
paddingBottom,
isEntering,
entryState,
}: DebugGroundOverlayProps) {
return (
<>
{/* Ground line where Blobbi's CONTAINER bottom should be */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: groundY + size,
height: 2,
backgroundColor: 'red',
zIndex: 10002,
}}
/>
<div
style={{
position: 'fixed',
right: 10,
top: groundY + size + 4,
color: 'red',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Container bottom (groundY + size = {Math.round(groundY + size)}px)
</div>
{/* Viewport bottom minus padding */}
<div
style={{
position: 'fixed',
left: 0,
right: 0,
top: viewportHeight - paddingBottom,
height: 2,
backgroundColor: 'blue',
zIndex: 10002,
}}
/>
<div
style={{
position: 'fixed',
right: 10,
top: viewportHeight - paddingBottom + 4,
color: 'blue',
fontSize: 12,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '2px 4px',
}}
>
Viewport - padding = {viewportHeight - paddingBottom}px (Target ground)
</div>
{/* Entry type indicator */}
{isEntering && (
<div
style={{
position: 'fixed',
left: 10,
top: 10,
color: entryState.entryType === 'fall' ? 'orange' : 'green',
fontSize: 14,
fontWeight: 'bold',
zIndex: 10002,
backgroundColor: 'white',
padding: '4px 8px',
borderRadius: 4,
}}
>
Entry: {entryState.entryType.toUpperCase()} | Phase: {entryState.phase}
</div>
)}
</>
);
}
@@ -0,0 +1,43 @@
/**
* useActionEmotionOverride — Temporary emotion override when using items.
*
* When an item is used on the companion (e.g., feeding → happy), this hook
* provides a short-lived emotion override that takes precedence over the
* status reaction system. The override automatically clears after 1.5s.
*
* Used by BlobbiCompanionLayer to wrap item-use handlers with emotion feedback.
*/
import { useState, useCallback, useRef } from 'react';
import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
/** Duration of the action emotion override in milliseconds. */
const ACTION_OVERRIDE_DURATION_MS = 1500;
interface UseActionEmotionOverrideResult {
/** Current override emotion, or null if none active. Passed to useStatusReaction. */
actionOverride: BlobbiEmotion | null;
/** Trigger an override for the given action type. */
triggerOverride: (action: ActionType) => void;
}
export function useActionEmotionOverride(): UseActionEmotionOverrideResult {
const [actionOverride, setActionOverride] = useState<BlobbiEmotion | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerOverride = useCallback((action: ActionType) => {
// Clear any existing timer
if (timerRef.current) {
clearTimeout(timerRef.current);
}
setActionOverride(getActionEmotion(action));
timerRef.current = setTimeout(() => {
setActionOverride(null);
timerRef.current = null;
}, ACTION_OVERRIDE_DURATION_MS);
}, []);
return { actionOverride, triggerOverride };
}
@@ -12,7 +12,6 @@ import type {
CompanionData,
CompanionState,
CompanionMotion,
GazeState,
EyeOffset,
Position,
MovementBounds,
@@ -20,8 +19,17 @@ import type {
EntryType,
InspectionDirection,
} from '../types/companion.types';
/** Default motion state used before motion hook initializes */
const DEFAULT_MOTION: CompanionMotion = {
position: { x: 0, y: 0 },
velocity: { x: 0, y: 0 },
direction: 'right',
isGrounded: true,
isDragging: false,
};
import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig';
import { calculateMovementBounds, calculateGroundY, calculateRestingPosition } from '../utils/movement';
import { calculateMovementBounds, calculateGroundY } from '../utils/movement';
import { useBlobbiCompanionData } from './useBlobbiCompanionData';
import { useBlobbiCompanionState } from './useBlobbiCompanionState';
import { useBlobbiCompanionMotion } from './useBlobbiCompanionMotion';
@@ -50,10 +58,8 @@ interface UseBlobbiCompanionResult {
state: CompanionState;
/** Current motion state */
motion: CompanionMotion;
/** Current gaze state */
gaze: GazeState;
/** Smoothed eye offset for rendering */
eyeOffset: EyeOffset;
/** Ref-based eye offset for imperative gaze control (no rerenders) */
eyeOffsetRef: React.RefObject<EyeOffset>;
/** Whether entry animation is playing */
isEntering: boolean;
/** Entry animation progress (0-1) - legacy, use entryState for detailed control */
@@ -128,10 +134,14 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
y: groundY,
}), [viewport.width, config.size, groundY]);
const restingPosition = useMemo(() =>
calculateRestingPosition(viewport.width, viewport.height, config.size, config),
[viewport.width, viewport.height, config]
);
// Shared motion ref - motion hook writes, state hook reads
// This solves the bidirectional dependency: state needs motion position,
// motion needs state/targetX. By using a ref, state can read current motion
// without creating a circular hook dependency.
const motionRef = useRef<CompanionMotion>({
...DEFAULT_MOTION,
position: groundPosition,
});
// Fetch companion data
const { companion, isLoading } = useBlobbiCompanionData();
@@ -200,7 +210,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
}, config.attention.postRouteDelay);
}, [findMainContentPosition, triggerAttention, config.attention.postRouteDuration, config.attention.postRouteDelay]);
// Determine if companion is sleeping
const companionSleeping = companion?.state === 'sleeping';
// State management
// Pass the shared motionRef so state can read live motion values
const {
state,
direction,
@@ -210,19 +224,15 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
onReachedTarget,
} = useBlobbiCompanionState({
isActive: isVisible,
motion: {
position: restingPosition,
velocity: { x: 0, y: 0 },
direction: 'right',
isGrounded: true,
isDragging: false
},
motionRef,
bounds,
attentionTarget: currentAttention,
isSleeping: companionSleeping,
});
// Motion management
// After entry completes, motion continues from groundPosition (where entry ended)
// Pass sharedMotionRef so state hook can read live motion values
const {
motion,
startDrag,
@@ -237,6 +247,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
targetX,
energy: companion?.energy ?? 50,
onReachedTarget,
sharedMotionRef: motionRef,
});
// Entry animation management (handles route changes and companion changes)
@@ -292,7 +303,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
}, [entryJustCompleted, wasResolvedFromStuck, setPosition, groundPosition, acknowledgeCompletion]);
// Gaze management - passes entry inspection direction for eye control during entry
const { gaze, eyeOffset } = useBlobbiCompanionGaze({
const { eyeOffsetRef } = useBlobbiCompanionGaze({
state: isEntering ? 'idle' : state,
direction: isEntering ? 'right' : direction,
companionPosition: motion.position,
@@ -310,8 +321,7 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
isVisible: shouldBeVisible,
state: isEntering ? 'idle' : state,
motion,
gaze,
eyeOffset,
eyeOffsetRef,
isEntering,
entryProgress: entryState.progress,
entryState,
@@ -4,22 +4,18 @@
* Fetches the current companion data from the user's Blobbonaut profile.
* This is the data layer - it handles fetching and provides companion data.
*
* IMPORTANT: This hook uses useBlobbonautProfile to ensure reactivity.
* When the profile is updated (e.g., companion selected/removed), this hook
* automatically receives the update via the shared query cache.
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
* useBlobbisCollection. This ensures:
* - Immediate reactivity when stats change (optimistic updates)
* - Projected decay is applied for accurate visual reactions
* - No duplicate queries or stale cache issues
*/
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/lib/blobbi';
import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import type { CompanionData } from '../types/companion.types';
interface UseBlobbiCompanionDataResult {
@@ -36,79 +32,83 @@ interface UseBlobbiCompanionDataResult {
*
* Flow:
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
* 2. Read the currentCompanion from the profile
* 3. If it exists, fetch the corresponding kind 31124 (Blobbi State) event
* 4. Return the minimal data needed for rendering
* 2. Build a dList containing just the currentCompanion
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
* 4. Apply projected decay for accurate UI reactions
* 5. Return the companion data with projected stats
*
* Reactivity:
* - Uses the same query cache as useBlobbonautProfile
* - When profile is updated via updateProfileEvent(), this hook reacts immediately
* - No duplicate queries or stale cache issues
* - Uses the same query cache as BlobbiPage (blobbi-collection)
* - When Blobbi state is updated, optimistic updates flow through immediately
* - Projected decay recalculates every 60 seconds
* - No separate query or stale cache issues
*/
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// Use the shared profile hook - this ensures reactivity when profile changes
const { profile, isLoading: profileLoading } = useBlobbonautProfile();
// Extract current companion d-tag from the reactive profile
const currentCompanionD = profile?.currentCompanion;
// Fetch the Blobbi state if we have a current companion
const blobbiQuery = useQuery({
queryKey: ['companion-blobbi', user?.pubkey, currentCompanionD],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !currentCompanionD) return null;
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [currentCompanionD],
}], { signal });
// Get the latest valid event
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbiEvent(validEvents[0]);
},
enabled: !!user?.pubkey && !!currentCompanionD,
staleTime: 60_000, // 1 minute
gcTime: 5 * 60_000, // 5 minutes
});
// Build dList containing just the current companion (if set)
// This allows us to use the shared collection query cache
const dList = useMemo(() => {
if (!currentCompanionD) return undefined;
return [currentCompanionD];
}, [currentCompanionD]);
// Transform to CompanionData
// Use the shared collection query - same cache as BlobbiPage
// This ensures we get optimistic updates immediately
const {
companionsByD,
isLoading: collectionLoading,
} = useBlobbisCollection(dList);
// Get the BlobbiCompanion from the collection
const blobbi = currentCompanionD ? companionsByD[currentCompanionD] ?? null : null;
// Apply projected decay for accurate visual reactions
// This recalculates every 60 seconds while mounted
const projectedState = useProjectedBlobbiState(blobbi);
// Transform to CompanionData with projected stats
// When currentCompanionD becomes null/undefined, companion becomes null
const companion = useMemo((): CompanionData | null => {
// If no current companion is set in profile, return null immediately
// This ensures removal is reactive
if (!currentCompanionD) return null;
const blobbi = blobbiQuery.data;
if (!blobbi) return null;
// Only baby and adult can be companions
if (blobbi.stage === 'egg') return null;
// Use projected stats if available, otherwise fall back to base stats
const stats = projectedState?.stats ?? blobbi.stats;
return {
d: blobbi.d,
name: blobbi.name,
stage: blobbi.stage,
visualTraits: blobbi.visualTraits,
energy: blobbi.stats.energy ?? 100,
energy: stats.energy ?? 100,
stats: {
hunger: stats.hunger ?? 100,
happiness: stats.happiness ?? 100,
health: stats.health ?? 100,
hygiene: stats.hygiene ?? 100,
energy: stats.energy ?? 100,
},
state: blobbi.state,
// Include adult form info for proper rendering
adultType: blobbi.adultType,
seed: blobbi.seed,
};
}, [currentCompanionD, blobbiQuery.data]);
}, [currentCompanionD, blobbi, projectedState?.stats]);
return {
companion,
isLoading: profileLoading || (!!currentCompanionD && blobbiQuery.isLoading),
error: blobbiQuery.error ?? null,
isLoading: profileLoading || (!!currentCompanionD && collectionLoading),
error: null,
};
}
@@ -49,10 +49,8 @@ interface UseBlobbiCompanionGazeOptions {
}
interface UseBlobbiCompanionGazeResult {
/** Current gaze state */
gaze: GazeState;
/** Smoothed eye offset for rendering */
eyeOffset: EyeOffset;
/** Ref-based eye offset for imperative gaze control (no rerenders) */
eyeOffsetRef: React.RefObject<EyeOffset>;
}
/**
@@ -94,8 +92,11 @@ export function useBlobbiCompanionGaze({
attentionPosition,
entryInspectionDirection,
}: UseBlobbiCompanionGazeOptions): UseBlobbiCompanionGazeResult {
const [gaze, setGaze] = useState<GazeState>(createInitialGaze);
const [eyeOffset, setEyeOffset] = useState<EyeOffset>({ x: 0, y: 0 });
const [, setGaze] = useState<GazeState>(createInitialGaze);
// Eye offset is driven imperatively via ref — no React state needed.
// The RAF loop writes to eyeOffsetRef; useExternalEyeOffset reads from it.
/** Ref-based eye offset for imperative consumers (avoids per-frame React rerenders) */
const eyeOffsetRef = useRef<EyeOffset>({ x: 0, y: 0 });
const [mousePosition, setMousePosition] = useState<Position | null>(null);
// Use refs for values that shouldn't trigger re-renders
@@ -109,8 +110,25 @@ export function useBlobbiCompanionGaze({
const mouseFollowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mouseFollowCheckTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Refs for frequently changing values used in animation loop
// This prevents RAF effect from being torn down on every position change
const directionRef = useRef(direction);
const companionPositionRef = useRef(companionPosition);
const companionSizeRef = useRef(companionSize);
const mousePositionRef = useRef(mousePosition);
const observationTargetRef = useRef(observationTarget);
const attentionPositionRef = useRef(attentionPosition);
const config = DEFAULT_COMPANION_CONFIG;
// Keep refs updated with latest values
useEffect(() => { directionRef.current = direction; }, [direction]);
useEffect(() => { companionPositionRef.current = companionPosition; }, [companionPosition]);
useEffect(() => { companionSizeRef.current = companionSize; }, [companionSize]);
useEffect(() => { mousePositionRef.current = mousePosition; }, [mousePosition]);
useEffect(() => { observationTargetRef.current = observationTarget; }, [observationTarget]);
useEffect(() => { attentionPositionRef.current = attentionPosition; }, [attentionPosition]);
// Clear all timers helper
const clearAllTimers = useCallback(() => {
if (randomGazeTimerRef.current) {
@@ -275,6 +293,9 @@ export function useBlobbiCompanionGaze({
}, [isActive, state, observationTarget, attentionPosition, entryInspectionDirection, config.gaze.randomInterval, config.gaze.mouseFollowCooldown, config.gaze.mouseFollowChance, config.gaze.mouseFollowDuration, clearAllTimers]);
// Animation loop for smooth eye movement
// IMPORTANT: This effect only depends on isActive to start/stop the loop.
// All other values are read from refs to prevent loop recreation on every
// position change (which caused jitter and stuck eyes after entry).
useEffect(() => {
if (!isActive) {
if (animationRef.current) {
@@ -285,6 +306,14 @@ export function useBlobbiCompanionGaze({
}
const animate = () => {
// Read current values from refs (not closure captures)
const currentPosition = companionPositionRef.current;
const currentSize = companionSizeRef.current;
const currentDirection = directionRef.current;
const currentMouse = mousePositionRef.current;
const currentObservation = observationTargetRef.current;
const currentAttention = attentionPositionRef.current;
// Calculate target offset based on current gaze mode
let targetOffset: EyeOffset;
@@ -294,19 +323,19 @@ export function useBlobbiCompanionGaze({
// During entry inspection - use the pre-set target offset from inspection direction
// This is set by the main effect when entryInspectionDirection changes
targetOffset = targetOffsetRef.current;
} else if (currentMode === 'attend-ui' && attentionPosition) {
} else if (currentMode === 'attend-ui' && currentAttention) {
// Look at UI element that appeared - calculate offset to that position
targetOffset = calculateEyeOffset(companionPosition, attentionPosition, companionSize);
} else if (currentMode === 'observe-target' && observationTarget) {
targetOffset = calculateEyeOffset(currentPosition, currentAttention, currentSize);
} else if (currentMode === 'observe-target' && currentObservation) {
// Look at observation target - calculate offset to that position
targetOffset = calculateEyeOffset(companionPosition, observationTarget, companionSize);
} else if (currentMode === 'follow-mouse' && mousePosition) {
targetOffset = calculateEyeOffset(currentPosition, currentObservation, currentSize);
} else if (currentMode === 'follow-mouse' && currentMouse) {
// Follow mouse cursor
targetOffset = calculateEyeOffset(companionPosition, mousePosition, companionSize);
targetOffset = calculateEyeOffset(currentPosition, currentMouse, currentSize);
} else if (currentMode === 'forward') {
// Look in movement direction - STRONGER offset for clear visual feedback
targetOffset = {
x: direction === 'right' ? 0.85 : -0.85,
x: currentDirection === 'right' ? 0.85 : -0.85,
y: 0.15, // Slightly down, looking at path ahead
};
} else {
@@ -329,10 +358,13 @@ export function useBlobbiCompanionGaze({
: currentMode === 'forward' ? 0.12
: 0.06;
setEyeOffset(prev => ({
x: smoothLerp(prev.x, targetOffset.x, smoothFactor),
y: smoothLerp(prev.y, targetOffset.y, smoothFactor),
}));
// Update the ref imperatively (no React rerender) — companion visual reads from this
const prevOffset = eyeOffsetRef.current;
const newOffset = {
x: smoothLerp(prevOffset.x, targetOffset.x, smoothFactor),
y: smoothLerp(prevOffset.y, targetOffset.y, smoothFactor),
};
eyeOffsetRef.current = newOffset;
animationRef.current = requestAnimationFrame(animate);
};
@@ -345,10 +377,9 @@ export function useBlobbiCompanionGaze({
animationRef.current = null;
}
};
}, [isActive, direction, companionPosition, mousePosition, companionSize, observationTarget, attentionPosition, entryInspectionDirection]);
}, [isActive]); // ONLY depend on isActive - all other values read from refs
return {
gaze,
eyeOffset,
eyeOffsetRef,
};
}
@@ -5,7 +5,7 @@
* This includes walking, gravity, and drag behavior.
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, type MutableRefObject } from 'react';
import type {
CompanionState,
@@ -37,6 +37,12 @@ interface UseBlobbiCompanionMotionOptions {
energy: number;
/** Callback when target is reached */
onReachedTarget: () => void;
/**
* Shared ref to sync motion state with state hook.
* This allows the state hook to read live motion values without
* creating a circular dependency.
*/
sharedMotionRef?: MutableRefObject<CompanionMotion>;
}
interface UseBlobbiCompanionMotionResult {
@@ -63,6 +69,7 @@ export function useBlobbiCompanionMotion({
targetX,
energy,
onReachedTarget,
sharedMotionRef,
}: UseBlobbiCompanionMotionOptions): UseBlobbiCompanionMotionResult {
const [motion, setMotion] = useState<CompanionMotion>(() =>
createInitialMotion(initialX, groundY)
@@ -72,6 +79,13 @@ export function useBlobbiCompanionMotion({
const lastTimeRef = useRef<number>(0);
const config = DEFAULT_COMPANION_CONFIG;
// Sync motion to shared ref so state hook can read it
useEffect(() => {
if (sharedMotionRef) {
sharedMotionRef.current = motion;
}
}, [motion, sharedMotionRef]);
// Animation loop
useEffect(() => {
const animate = (time: number) => {
@@ -5,7 +5,7 @@
* This is the state layer - it handles state transitions and timing.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, type MutableRefObject } from 'react';
import type {
CompanionState,
@@ -21,14 +21,20 @@ import { DEFAULT_COMPANION_CONFIG, randomDuration } from '../core/companionConfi
interface UseBlobbiCompanionStateOptions {
/** Whether the companion is active and should be making decisions */
isActive: boolean;
/** Current motion state (used for position/dragging checks) */
motion: CompanionMotion;
/**
* Ref to current motion state (shared with motion hook).
* Using a ref allows state to read live motion values without
* creating a circular dependency between state and motion hooks.
*/
motionRef: MutableRefObject<CompanionMotion>;
/** Movement bounds */
bounds: MovementBounds;
/** Whether to force walking on first activation (after entry) */
forceInitialWalk?: boolean;
/** Current attention target (from UI attention system) */
attentionTarget?: AttentionTarget | null;
/** Whether the companion is sleeping (freezes all decisions/movement) */
isSleeping?: boolean;
}
interface UseBlobbiCompanionStateResult {
@@ -51,10 +57,11 @@ interface UseBlobbiCompanionStateResult {
*/
export function useBlobbiCompanionState({
isActive,
motion,
motionRef,
bounds,
forceInitialWalk = true,
attentionTarget,
isSleeping = false,
}: UseBlobbiCompanionStateOptions): UseBlobbiCompanionStateResult {
const [state, setState] = useState<CompanionState>('idle');
const [direction, setDirection] = useState<CompanionDirection>('right');
@@ -67,14 +74,11 @@ export function useBlobbiCompanionState({
const timerRef = useRef<number | null>(null);
const hasHadInitialWalk = useRef(false);
const motionRef = useRef(motion);
const lastObservationTimeRef = useRef<number>(0);
const config = DEFAULT_COMPANION_CONFIG;
// Keep motion ref updated
useEffect(() => {
motionRef.current = motion;
}, [motion]);
// motionRef is now passed in from the orchestrator and shared with motion hook
// No need for local ref or sync effect - just read directly from motionRef.current
// Clear timer on cleanup
useEffect(() => {
@@ -136,7 +140,7 @@ export function useBlobbiCompanionState({
// Make a decision about what to do next
const makeDecision = useCallback(() => {
if (!isActive || motionRef.current.isDragging) {
if (!isActive || isSleeping || motionRef.current.isDragging) {
return;
}
@@ -172,7 +176,7 @@ export function useBlobbiCompanionState({
// Schedule next decision
const duration = transition.duration ?? randomDuration(config.idleTime);
timerRef.current = window.setTimeout(makeDecision, duration);
}, [isActive, bounds, state, config, startObservation]);
}, [isActive, isSleeping, bounds, state, config, startObservation]);
// Handle reaching target
const onReachedTarget = useCallback(() => {
@@ -207,9 +211,22 @@ export function useBlobbiCompanionState({
}
}, [makeDecision, observationTarget, config.observation.lookDuration]);
// Start decision loop when active
// Force idle when sleeping - stop all movement/decisions immediately
useEffect(() => {
if (isActive && !motionRef.current.isDragging) {
if (isSleeping) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setState('idle');
setTargetX(null);
setObservationTarget(null);
}
}, [isSleeping]);
// Start decision loop when active (and not sleeping)
useEffect(() => {
if (isActive && !isSleeping && !motionRef.current.isDragging) {
// Clear any existing timer
if (timerRef.current) {
clearTimeout(timerRef.current);
@@ -238,19 +255,33 @@ export function useBlobbiCompanionState({
clearTimeout(timerRef.current);
}
};
}, [isActive, forceInitialWalk, startInitialWalk, makeDecision]);
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
// Pause decisions while dragging
// We poll isDragging via interval since motionRef changes don't trigger re-renders
useEffect(() => {
if (motion.isDragging) {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
if (!isActive) return;
let wasDragging = false;
const checkDragging = () => {
const isDragging = motionRef.current.isDragging;
if (isDragging && !wasDragging) {
// Started dragging
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setState('idle');
setTargetX(null);
}
setState('idle');
setTargetX(null);
}
}, [motion.isDragging]);
wasDragging = isDragging;
};
// Check frequently for drag state changes
const interval = setInterval(checkDragging, 100);
return () => clearInterval(interval);
}, [isActive, motionRef]);
// Handle attention targets - interrupt current behavior when UI elements appear
useEffect(() => {
@@ -24,7 +24,7 @@ import {
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiStats,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import { checkItemCategoryNeed, type NeedCheckResult } from '../interaction/needDetection';
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import type { Position } from '../types/companion.types';
@@ -100,7 +100,6 @@ export function BlobbiActionsProvider({ children }: BlobbiActionsProviderProps)
const registerRef = useRef<UseItemFunction | null>(null);
const canUseItemsRegisteredRef = useRef<boolean>(false);
const isUsingItemRegisteredRef = useRef<boolean>(false);
// Subscribers for manual notification
const subscribersRef = useRef<Set<() => void>>(new Set());
@@ -35,6 +35,8 @@ interface CompanionActionMenuProps {
onActionClick: (action: CompanionMenuAction) => void;
/** Callback for clicking outside the menu */
onClickOutside?: () => void;
/** Whether Blobbi is currently sleeping (affects sleep button label) */
isSleeping?: boolean;
}
// Layout configuration
@@ -90,6 +92,7 @@ export function CompanionActionMenu({
selectedAction,
onActionClick,
onClickOutside,
isSleeping = false,
}: CompanionActionMenuProps) {
if (!isOpen) return null;
@@ -122,6 +125,11 @@ export function CompanionActionMenu({
const isSelected = selectedAction === action.id;
const delay = index * MENU_CONFIG.staggerDelay;
// Sleep action toggles label/emoji based on sleeping state
const isSleepAction = action.id === 'sleep';
const displayEmoji = isSleepAction && isSleeping ? '\u2600\uFE0F' : action.emoji;
const displayLabel = isSleepAction && isSleeping ? 'Wake up' : action.label;
return (
<button
key={action.id}
@@ -155,15 +163,15 @@ export function CompanionActionMenu({
e.stopPropagation();
onActionClick(action.id);
}}
title={action.label}
aria-label={action.label}
title={displayLabel}
aria-label={displayLabel}
>
<span
className="text-xl select-none"
role="img"
aria-hidden="true"
>
{action.emoji}
{displayEmoji}
</span>
</button>
);
@@ -10,7 +10,7 @@
* - Returns both boolean need and priority level for potential future use
*/
import type { BlobbiStats } from '@/lib/blobbi';
import type { BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { ShopItemCategory } from '@/blobbi/shop/types/shop.types';
// ─── Need Thresholds ──────────────────────────────────────────────────────────
@@ -66,7 +66,6 @@ const CATEGORY_TO_PRIMARY_STAT: Record<ShopItemCategory, (keyof BlobbiStats)[]>
toy: ['happiness'],
hygiene: ['hygiene'],
medicine: ['health'],
accessory: [], // Accessories don't address needs
};
// ─── Need Detection Functions ─────────────────────────────────────────────────
@@ -27,7 +27,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
@@ -36,8 +36,8 @@ import {
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/lib/blobbi';
import { applyBlobbiDecay } from '@/lib/blobbi-decay';
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
@@ -188,14 +188,45 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr, user?.pubkey, profile?.currentCompanion, options.companion]);
// Update companion in query cache
const updateCompanionInCache = useCallback((_event: NostrEvent) => {
// Update companion in query cache - optimistic update for immediate UI refresh
const updateCompanionInCache = useCallback((event: NostrEvent) => {
if (!user?.pubkey || !profile?.currentCompanion) return;
// Invalidate and update the companion query
queryClient.invalidateQueries({
queryKey: ['companion-blobbi', user.pubkey, profile.currentCompanion]
});
// Parse the new event to get the updated companion
const parsed = parseBlobbiEvent(event);
if (!parsed) {
// Fallback to invalidation if parsing fails
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
return;
}
// Optimistically update the blobbi-collection cache
// This ensures the companion layer sees the update immediately
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] } | undefined>(
// Use partial key match - React Query will find any matching query
['blobbi-collection', user.pubkey],
(prev) => {
if (!prev) return prev;
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
},
);
// Also invalidate to trigger background refetch (ensures consistency)
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
@@ -0,0 +1,187 @@
/**
* useBlobbiSleepToggle — Standalone sleep/wake toggle for the companion.
*
* This hook mirrors the essential logic of BlobbiPage's `handleRest` but
* works independently — it fetches fresh event data from relays, publishes
* the state change, and updates the TanStack Query cache directly.
*
* This eliminates the dependency on BlobbiPage being mounted. The companion
* sleep button works on any page.
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { trackDailyMissionProgress } from '@/blobbi/actions/lib/daily-mission-tracker';
export interface UseBlobbiSleepToggleResult {
/** Toggle sleep/wake state. Resolves when published. */
toggleSleep: () => Promise<void>;
/** Whether a toggle is currently in progress. */
isPending: boolean;
}
export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const { profile } = useBlobbonautProfile();
// Track pending state via ref to avoid re-renders.
// We only use this for the guard (no duplicate calls), not for rendering.
const pendingRef = useRef(false);
/** Fetch the latest companion event directly from relays. */
const fetchFreshCompanion = useCallback(async (
pubkey: string,
dTag: string,
): Promise<BlobbiCompanion | null> => {
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#d': [dTag],
}]);
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr]);
/** Optimistically update the TanStack cache so the companion reacts immediately. */
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
const parsed = parseBlobbiEvent(event);
if (!parsed) {
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
return;
}
// Optimistically update ALL blobbi-collection queries for this user.
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
// partial matching to find all entries regardless of dList shape.
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
const matchingQueries = queryClient.getQueriesData<CollectionData>({
queryKey: ['blobbi-collection', pubkey],
});
for (const [queryKey, data] of matchingQueries) {
if (!data) continue;
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
queryClient.setQueryData<CollectionData>(queryKey, {
companionsByD: newCompanionsByD,
companions: Object.values(newCompanionsByD),
});
}
// Also invalidate for background refetch to ensure eventual consistency
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
}, [queryClient]);
const toggleSleep = useCallback(async () => {
if (pendingRef.current) return;
if (!user?.pubkey || !profile?.currentCompanion) {
if (import.meta.env.DEV) {
console.warn('[SleepToggle] No user or no current companion');
}
return;
}
pendingRef.current = true;
try {
// Fetch the freshest event from relays (read-modify-write)
const companion = await fetchFreshCompanion(user.pubkey, profile.currentCompanion);
if (!companion) {
toast({
title: 'Cannot change state',
description: 'Companion not found. Please try again.',
variant: 'destructive',
});
return;
}
const isCurrentlySleeping = companion.state === 'sleeping';
const newState = isCurrentlySleeping ? 'active' : 'sleeping';
// Apply accumulated decay before the state change
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now,
});
const nowStr = now.toString();
// Streak updates (putting to sleep/waking counts as care activity)
const streakUpdates = getStreakTagUpdates(companion) ?? {};
const newTags = updateBlobbiTags(companion.allTags, {
state: newState,
hunger: decayResult.stats.hunger.toString(),
happiness: decayResult.stats.happiness.toString(),
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
energy: decayResult.stats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
tags: newTags,
});
// Optimistic cache update + background invalidation
updateCache(event, user.pubkey);
toast({
title: isCurrentlySleeping ? 'Woke up!' : 'Resting...',
description: isCurrentlySleeping
? 'Your Blobbi is now awake and active!'
: 'Your Blobbi is taking a rest.',
});
// Track daily mission progress (only when putting to sleep)
if (!isCurrentlySleeping) {
trackDailyMissionProgress('sleep', 1, user.pubkey);
}
} catch (error) {
console.error('[SleepToggle] Failed:', error);
toast({
title: 'Failed to update',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
pendingRef.current = false;
}
}, [user?.pubkey, profile?.currentCompanion, fetchFreshCompanion, publishEvent, updateCache]);
return {
toggleSleep,
isPending: false, // ref-based, so always false for render — prevents unnecessary re-renders
};
}
@@ -19,7 +19,7 @@ import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/lib/blobbi';
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -82,7 +82,6 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
toy: 'play',
medicine: 'medicine',
hygiene: 'clean',
accessory: null, // Accessories are cosmetic, not usable
};
/**
@@ -5,7 +5,8 @@
* decoupled from app-specific concerns.
*/
import type { BlobbiVisualTraits } from '@/lib/blobbi';
import type { BlobbiVisualTraits, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiState } from '@/blobbi/core/types/blobbi';
// ─── Companion State Machine ──────────────────────────────────────────────────
@@ -176,6 +177,10 @@ export interface CompanionData {
visualTraits: BlobbiVisualTraits;
/** Current energy level (0-100) - affects walking speed */
energy: number;
/** Current stats for status-based visual reactions */
stats: BlobbiStats;
/** Current companion state (e.g., 'sleeping') */
state?: BlobbiState;
/** Adult evolution form type (e.g., 'catti', 'pupp', 'buni') - only for adults */
adultType?: string;
/** Deterministic seed for deriving traits */
@@ -318,8 +323,6 @@ export interface CompanionContextValue {
state: CompanionState;
/** Current motion state */
motion: CompanionMotion;
/** Current gaze state */
gaze: GazeState;
/** Start dragging the companion */
startDrag: () => void;
/** Update drag position */
+6 -7
View File
@@ -44,13 +44,12 @@ export function calculateFloatAnimation(time: number, isMoving: boolean): FloatO
// Multiple frequencies create a bouncy, charming walk
const t = time / 1000; // Convert to seconds for easier frequency tuning
// Primary bob - quick rhythmic bounce (about 2 bounces per second)
const primaryBob = Math.sin(t * 12) * 3;
// Secondary bob - slower wave that adds variation
const secondaryBob = Math.sin(t * 5 + 0.5) * 1.5;
// Slight lift during walk - don't stay on ground
const baseLift = -2;
const yOffset = baseLift + primaryBob * 0.5 + secondaryBob * 0.3;
// Vertical bob oscillates symmetrically around zero so Blobbi's base
// stays anchored to the ground line. The original baseLift = -2 was
// removed because it biased the offset permanently upward.
const primaryBob = Math.sin(t * 12) * 2; // Reduced from *3: less vertical energy
const secondaryBob = Math.sin(t * 5 + 0.5) * 1;
const yOffset = primaryBob * 0.4 + secondaryBob * 0.25;
// Horizontal sway - playful side-to-side motion
const primarySway = Math.sin(t * 6) * 2;
+299
View File
@@ -0,0 +1,299 @@
import { useCallback } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
buildMigrationTags,
generatePetId10,
getCanonicalBlobbiD,
migratePetInHas,
updateBlobbonautTags,
parseBlobbiEvent,
parseStorageTags,
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
} from '../lib/blobbi';
/**
* Result of a successful migration.
*/
export interface MigrationResult {
/** The new canonical d-tag */
canonicalD: string;
/** The published canonical Blobbi event */
event: NostrEvent;
/** The parsed canonical BlobbiCompanion */
companion: BlobbiCompanion;
/** The updated profile event */
profileEvent: NostrEvent;
/** The updated profile tags (canonical has, current_companion, etc.) */
profileTags: string[][];
/** The profile storage (unchanged during migration, but fresh from migrated profile) */
profileStorage: StorageItem[];
}
/**
* Options for the migration helper.
*/
export interface EnsureCanonicalOptions {
/** The companion to check/migrate */
companion: BlobbiCompanion;
/** The user's profile */
profile: BlobbonautProfile;
/** Callback to update the profile event in query cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Callback to update the companion event in query cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Callback to update localStorage selection if it was pointing to legacy d */
updateStoredSelectedD?: (newD: string) => void;
/** Callback to invalidate companion query */
invalidateCompanion?: () => void;
/** Callback to invalidate profile query */
invalidateProfile?: () => void;
}
/**
* Result of ensureCanonicalBlobbiBeforeAction.
*/
export interface EnsureCanonicalResult {
/** Whether the companion was migrated */
wasMigrated: boolean;
/** The canonical companion (either the original or the migrated one) */
companion: BlobbiCompanion;
/** The canonical event tags to use for the action */
allTags: string[][];
/** The event content to use */
content: string;
/**
* The latest profile tags to use for profile updates.
* IMPORTANT: Always use these instead of profile.allTags from hook closure
* to avoid restoring stale/legacy values after migration.
*/
profileAllTags: string[][];
/**
* The latest profile storage to use.
* Use this as the base for storage modifications.
*/
profileStorage: StorageItem[];
}
/**
* Hook providing centralized migration logic for Blobbi companions.
*
* This hook should be used by all action handlers to ensure legacy Blobbis
* are automatically migrated before any interaction.
*
* Usage:
* ```ts
* const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
*
* const handleFeed = async () => {
* const result = await ensureCanonicalBlobbiBeforeAction({
* companion,
* profile,
* updateProfileEvent,
* updateCompanionEvent,
* updateStoredSelectedD: setStoredSelectedD,
* });
*
* if (!result) return; // Migration failed
*
* // Continue with the action using result.companion and result.allTags
* const newTags = updateBlobbiTags(result.allTags, { ... });
* // ... publish event
* };
* ```
*/
export function useBlobbiMigration() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
/**
* Migrate a legacy Blobbi to canonical format.
*
* This function:
* 1. Generates a canonical d-tag
* 2. Ensures a seed exists (generates one if missing)
* 3. Preserves name, stage, stats, state, timestamps
* 4. Publishes a canonical 31124 event
* 5. Updates the Blobbonaut profile (kind 11125)
* 6. Updates local state (query cache, localStorage)
*/
const migrateLegacyBlobbi = useCallback(async (
options: EnsureCanonicalOptions
): Promise<MigrationResult | null> => {
const {
companion,
profile,
updateProfileEvent,
updateCompanionEvent,
updateStoredSelectedD,
invalidateCompanion,
invalidateProfile,
} = options;
if (!user?.pubkey) {
console.error('[Blobbi Migration] No user pubkey');
return null;
}
console.log('[Blobbi Migration] Starting migration for:', companion.d);
try {
// Generate new canonical d-tag
const newPetId = generatePetId10();
const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId);
// Build migration tags (preserves name, stage, stats, generates seed if missing)
const migrationTags = buildMigrationTags(companion.event, newPetId, user.pubkey);
console.log('[Blobbi Migration] Publishing canonical event with d:', canonicalD);
// Publish the canonical Blobbi state
const canonicalEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content || `${companion.name} is a ${companion.stage} Blobbi.`,
tags: migrationTags,
});
// Parse the new event to get the canonical companion
const canonicalCompanion = parseBlobbiEvent(canonicalEvent);
if (!canonicalCompanion) {
throw new Error('Failed to parse migrated event');
}
// Update profile: replace legacy d with canonical d in has[], update current_companion
const updatedHas = migratePetInHas(profile.has, companion.d, canonicalD);
const shouldUpdateCurrentCompanion = profile.currentCompanion === companion.d;
const profileUpdates: Record<string, string | string[]> = {
has: updatedHas,
};
if (shouldUpdateCurrentCompanion) {
profileUpdates.current_companion = canonicalD;
}
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
console.log('[Blobbi Migration] Publishing updated profile');
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
// Update query caches
updateProfileEvent(profileEvent);
updateCompanionEvent(canonicalEvent);
// Update localStorage selection if it was pointing to legacy d
if (updateStoredSelectedD) {
console.log('[Blobbi Migration] Updating localStorage selection:', canonicalD);
updateStoredSelectedD(canonicalD);
}
// Invalidate queries to refetch fresh data
invalidateCompanion?.();
invalidateProfile?.();
toast({
title: 'Pet upgraded!',
description: `${companion.name} has been migrated to the new format.`,
});
console.log('[Blobbi Migration] Migration complete:', {
legacyD: companion.d,
canonicalD,
});
// Parse storage from the migrated profile tags
// Storage itself doesn't change during migration, but we need fresh tags
const migratedStorage = parseStorageTags(profileTags);
return {
canonicalD,
event: canonicalEvent,
companion: canonicalCompanion,
profileEvent,
profileTags,
profileStorage: migratedStorage,
};
} catch (error) {
console.error('[Blobbi Migration] Migration failed:', error);
toast({
title: 'Migration failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
return null;
}
}, [user?.pubkey, publishEvent]);
/**
* Ensure a Blobbi is in canonical format before performing an action.
*
* If the companion is legacy, it will be migrated first.
* Returns the canonical companion to use for the action.
*
* Flow:
* 1. Check if Blobbi is legacy
* 2. If legacy: migrate Blobbi
* 3. Return the resolved canonical Blobbi
*
* All interaction handlers should call this before publishing events.
*/
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
options: EnsureCanonicalOptions
): Promise<EnsureCanonicalResult | null> => {
const { companion, profile } = options;
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
const migrationResult = await migrateLegacyBlobbi(options);
if (!migrationResult) {
// Migration failed, cannot proceed with action
return null;
}
// Return the canonical companion AND migrated profile context
// CRITICAL: Consumers must use profileAllTags instead of profile.allTags
// to avoid restoring stale/legacy values
return {
wasMigrated: true,
companion: migrationResult.companion,
allTags: migrationResult.event.tags,
content: migrationResult.event.content,
profileAllTags: migrationResult.profileTags,
profileStorage: migrationResult.profileStorage,
};
}
// Companion is already canonical, return profile as-is
return {
wasMigrated: false,
companion,
allTags: companion.allTags,
content: companion.event.content,
profileAllTags: profile.allTags,
profileStorage: profile.storage,
};
}, [migrateLegacyBlobbi]);
return {
/** Migrate a legacy Blobbi to canonical format */
migrateLegacyBlobbi,
/** Ensure a Blobbi is canonical before an action, migrating if necessary */
ensureCanonicalBlobbiBeforeAction,
};
}
@@ -0,0 +1,198 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
} from '../lib/blobbi';
/** Maximum number of d-tags per query chunk to avoid relay issues */
const CHUNK_SIZE = 20;
/**
* Split an array into chunks of a given size.
*/
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
/**
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
*
* Features:
* - Fetches ALL pets by d-tag list (no limit: 1)
* - Chunks large d-lists into multiple queries for relay compatibility
* - Keeps only the newest event per d-tag
* - Returns both a lookup record and array of companions
* - Provides invalidation and optimistic update helpers
*/
export function useBlobbisCollection(dList: string[] | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Create a stable query key based on sorted d-tags
const sortedDList = useMemo(() => {
if (!dList || dList.length === 0) return null;
return [...dList].sort();
}, [dList]);
const queryKeyDTags = sortedDList?.join(',') ?? '';
// Main query to fetch all companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
// Chunk the d-list for relay compatibility
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
// Query all chunks in parallel
const allEvents: NostrEvent[] = [];
for (const chunk of chunks) {
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
};
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
const events = await nostr.query([filter], { signal });
allEvents.push(...events);
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
}
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
// Filter to valid events
const validEvents = allEvents.filter(isValidBlobbiEvent);
console.log('[useBlobbisCollection] Valid events:', validEvents.length);
// Group events by d-tag and keep only the newest per d
const eventsByD = new Map<string, NostrEvent>();
for (const event of validEvents) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (!dTag) continue;
const existing = eventsByD.get(dTag);
if (!existing || event.created_at > existing.created_at) {
eventsByD.set(dTag, event);
}
}
// Parse all events into BlobbiCompanion objects
const companionsByD: Record<string, BlobbiCompanion> = {};
const companions: BlobbiCompanion[] = [];
for (const [dTag, event] of eventsByD) {
const parsed = parseBlobbiEvent(event);
if (parsed) {
companionsByD[dTag] = parsed;
companions.push(parsed);
}
}
console.log('[useBlobbisCollection] Parsed companions:', {
count: companions.length,
dTags: Object.keys(companionsByD),
});
return { companionsByD, companions };
},
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Helper to invalidate and refetch after publishing
const invalidate = useCallback(() => {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
});
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Update a single companion event in the query cache (optimistic update)
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
['blobbi-collection', user.pubkey, queryKeyDTags],
(prev) => {
if (!prev) {
return {
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
};
}
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
}
);
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
const companionsByD = query.data?.companionsByD ?? {};
const companions = query.data?.companions ?? [];
return {
/** Record of companions keyed by d-tag */
companionsByD,
/** Array of all companions (newest per d-tag) */
companions,
/** True only when query is loading and no data available */
isLoading: query.isLoading,
/** True when actively fetching */
isFetching: query.isFetching,
/** True when data is stale */
isStale: query.isStale,
/** Query error if any */
error: query.error,
/** Invalidate and refetch the collection */
invalidate,
/** Optimistically update a single companion in the cache */
updateCompanionEvent,
};
}
@@ -0,0 +1,122 @@
/**
* Hook for projecting Blobbi decay state in the UI.
*
* This hook provides a local projection of decay without publishing events.
* It recalculates every 60 seconds while the component is mounted.
*
* The projected state is for UI display only. Actual mutations must
* recalculate from the persisted state before publishing.
*
* @see docs/blobbi/decay-system.md
*/
import { useState, useEffect, useMemo } from 'react';
import type { BlobbiCompanion, BlobbiStats } from '../lib/blobbi';
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/blobbi/core/lib/blobbi-decay';
/** UI refresh interval in milliseconds (60 seconds) */
const UI_REFRESH_INTERVAL_MS = 60_000;
/**
* Projected Blobbi state for UI display.
*/
export interface ProjectedBlobbiState {
/** Stats after applying projected decay */
stats: BlobbiStats;
/** Visible stats for the current stage with status indicators */
visibleStats: Array<{
stat: keyof BlobbiStats;
value: number;
status: 'critical' | 'warning' | 'normal';
}>;
/** Time elapsed since last decay (seconds) */
elapsedSeconds: number;
/** Timestamp of the projection calculation */
projectedAt: number;
/** Whether this is a fresh projection (recalculated this render) */
isFresh: boolean;
}
/**
* Hook to get a projected Blobbi state with decay applied.
*
* Features:
* - Immediately calculates projected state on mount/companion change
* - Recalculates every 60 seconds while mounted
* - Pure calculation - does not publish any events
* - Returns both full stats and stage-appropriate visible stats
*
* @param companion - The persisted Blobbi companion (source of truth)
* @returns Projected state with decay applied, or null if no companion
*/
export function useProjectedBlobbiState(
companion: BlobbiCompanion | null
): ProjectedBlobbiState | null {
// Track when we last recalculated
const [refreshTick, setRefreshTick] = useState(0);
// Set up 60-second refresh interval
useEffect(() => {
if (!companion) return;
const interval = setInterval(() => {
setRefreshTick(t => t + 1);
}, UI_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [companion]);
// Calculate projected state
const projectedState = useMemo((): ProjectedBlobbiState | null => {
if (!companion) return null;
const now = Math.floor(Date.now() / 1000);
// Apply decay from persisted state
const decayResult: DecayResult = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now,
});
// Get visible stats for the stage
const visibleStats = getVisibleStatsWithValues(companion.stage, decayResult.stats);
return {
stats: decayResult.stats,
visibleStats,
elapsedSeconds: decayResult.elapsedSeconds,
projectedAt: now,
isFresh: true,
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTick triggers recalculation
}, [companion, refreshTick]);
return projectedState;
}
/**
* Calculate projected decay for a companion at a specific timestamp.
*
* This is a utility function for use outside of React components,
* such as in mutation handlers before publishing.
*
* @param companion - The persisted Blobbi companion
* @param now - Unix timestamp to calculate decay to (defaults to current time)
* @returns Decay result with updated stats
*/
export function calculateProjectedDecay(
companion: BlobbiCompanion,
now?: number
): DecayResult {
return applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now: now ?? Math.floor(Date.now() / 1000),
});
}
+517
View File
@@ -0,0 +1,517 @@
/**
* Blobbi Decay System
*
* This module implements the continuous proportional decay system for Blobbi stats.
*
* Key principles:
* - Pure, deterministic calculation based on elapsed time
* - Floored stat changes before application
* - Stats clamped to 0-100 range
* - Stage-specific decay rates and health modifiers
* - Persisted state is the source of truth
*
* @see docs/blobbi/decay-system.md for full documentation
*/
import type { BlobbiStage, BlobbiState, BlobbiStats } from './blobbi';
import { STAT_MIN, STAT_MAX } from './blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of applying decay to a Blobbi.
* Contains updated stats and metadata about the calculation.
*/
export interface DecayResult {
/** Updated stats after decay (clamped to 0-100) */
stats: BlobbiStats;
/** Elapsed time in seconds that was used for decay calculation */
elapsedSeconds: number;
/** The timestamp that should be set as the new last_decay_at */
newDecayTimestamp: number;
}
/**
* Input parameters for decay calculation.
* Uses the persisted Blobbi state as source of truth.
*/
export interface DecayInput {
/** Current life stage */
stage: BlobbiStage;
/** Current activity state (awake/sleeping) */
state: BlobbiState;
/** Current stats from persisted state */
stats: Partial<BlobbiStats>;
/** Unix timestamp of last decay application */
lastDecayAt: number | undefined;
/** Current unix timestamp (defaults to now) */
now?: number;
}
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
/**
* Baby stage decay rates (per hour).
*
* Design goal: Needs attention every 3-5 hours.
*/
const BABY_DECAY = {
hunger: -7.0,
happiness: -4.0,
hygiene: -5.0,
energy: {
awake: -8.0,
sleeping: 6.0, // Regeneration
},
health: {
base: -0.75,
hungerBelow70: -0.75,
hungerBelow40: -1.25,
hygieneBelow70: -0.75,
hygieneBelow40: -1.25,
energyBelow50: -0.5,
energyBelow25: -1.0,
happinessBelow50: -0.5,
happinessBelow25: -1.0,
// Regeneration when all stats are >= 80
regenThreshold: 80,
regenRate: 1.5,
},
} as const;
/**
* Adult stage decay rates (per hour).
*
* Design goal: Needs attention every 5-7 hours.
*/
const ADULT_DECAY = {
hunger: -4.5,
happiness: -2.5,
hygiene: -3.5,
energy: {
awake: -5.0,
sleeping: 5.0, // Regeneration
},
health: {
base: -0.4,
hungerBelow60: -0.5,
hungerBelow30: -1.0,
hygieneBelow60: -0.5,
hygieneBelow30: -1.0,
energyBelow40: -0.4,
energyBelow20: -0.8,
happinessBelow40: -0.4,
happinessBelow20: -0.8,
// Regeneration when all stats are >= 80
regenThreshold: 80,
regenRate: 1.0,
},
} as const;
// ─── Constants: Warning Thresholds ────────────────────────────────────────────
/**
* Warning thresholds by stage.
* Warning = stat below this value indicates the Blobbi needs attention.
*/
export const WARNING_THRESHOLDS = {
egg: {
hygiene: 75,
health: 75,
happiness: 75,
},
baby: {
hunger: 65,
happiness: 65,
hygiene: 65,
energy: 65,
health: 65,
},
adult: {
hunger: 60,
happiness: 60,
hygiene: 60,
energy: 60,
health: 60,
},
} as const;
/**
* Critical thresholds by stage.
* Critical = stat below this value indicates urgent attention needed.
*/
export const CRITICAL_THRESHOLDS = {
egg: {
hygiene: 45,
health: 45,
happiness: 45,
},
baby: {
hunger: 35,
happiness: 35,
hygiene: 35,
energy: 25,
health: 35,
},
adult: {
hunger: 30,
happiness: 30,
hygiene: 30,
energy: 20,
health: 30,
},
} as const;
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Clamp a value to the STAT_MIN-STAT_MAX range (1-100).
* Stats can never reach true zero - minimum is always 1.
*/
function clamp(value: number): number {
return Math.max(STAT_MIN, Math.min(STAT_MAX, value));
}
/**
* Get stat value with fallback to 100 (full).
*/
function getStat(stats: Partial<BlobbiStats>, key: keyof BlobbiStats): number {
return stats[key] ?? 100;
}
/**
* Convert hours to the elapsed time unit for calculation.
* @param hours - Elapsed hours
* @returns Rate multiplier for the elapsed time
*/
function hoursFromSeconds(seconds: number): number {
return seconds / 3600;
}
/**
* Round a stat delta toward zero (truncate fractional part).
*
* CRITICAL: We use Math.trunc() instead of Math.floor() because:
* - Math.floor(-0.5) = -1 (rounds down, applying decay even with tiny elapsed time)
* - Math.trunc(-0.5) = 0 (rounds toward zero, no decay applied)
*
* This prevents the bug where any action within seconds of the last action
* would still apply -1 decay even though insufficient time passed.
*
* @param delta - Calculated stat change (can be positive or negative)
* @returns Integer delta to apply
*/
function roundDelta(delta: number): number {
return Math.trunc(delta);
}
// ─── Stage-Specific Decay Calculators ─────────────────────────────────────────
/**
* Calculate egg stage decay.
*
* Eggs only decay hygiene, health, and happiness.
* Hunger and energy are fixed at 100.
*/
function calculateEggDecay(
stats: Partial<BlobbiStats>,
_elapsedHours: number
): BlobbiStats {
// Eggs do not decay — all stats remain fixed until hatching.
return {
hunger: 100,
energy: 100,
hygiene: getStat(stats, 'hygiene'),
health: getStat(stats, 'health'),
happiness: getStat(stats, 'happiness'),
};
}
/**
* Calculate baby stage decay.
*/
function calculateBabyDecay(
stats: Partial<BlobbiStats>,
state: BlobbiState,
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Get current values
let hunger = getStat(stats, 'hunger');
let happiness = getStat(stats, 'happiness');
let hygiene = getStat(stats, 'hygiene');
let energy = getStat(stats, 'energy');
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = BABY_DECAY.hunger * elapsedHours;
const happinessDelta = BABY_DECAY.happiness * elapsedHours;
const hygieneDelta = BABY_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + roundDelta(hungerDelta));
happiness = clamp(happiness + roundDelta(happinessDelta));
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
energy = clamp(energy + roundDelta(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = BABY_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * elapsedHours;
if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * elapsedHours;
// Hygiene penalties
if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * elapsedHours;
if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * elapsedHours;
// Energy penalties
if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * elapsedHours;
if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * elapsedHours;
// Happiness penalties
if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * elapsedHours;
if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * elapsedHours;
// Health regeneration (all stats >= 80)
const threshold = BABY_DECAY.health.regenThreshold;
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + roundDelta(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
/**
* Calculate adult stage decay.
*/
function calculateAdultDecay(
stats: Partial<BlobbiStats>,
state: BlobbiState,
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Get current values
let hunger = getStat(stats, 'hunger');
let happiness = getStat(stats, 'happiness');
let hygiene = getStat(stats, 'hygiene');
let energy = getStat(stats, 'energy');
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = ADULT_DECAY.hunger * elapsedHours;
const happinessDelta = ADULT_DECAY.happiness * elapsedHours;
const hygieneDelta = ADULT_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + roundDelta(hungerDelta));
happiness = clamp(happiness + roundDelta(happinessDelta));
hygiene = clamp(hygiene + roundDelta(hygieneDelta));
energy = clamp(energy + roundDelta(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = ADULT_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * elapsedHours;
if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * elapsedHours;
// Hygiene penalties
if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * elapsedHours;
if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * elapsedHours;
// Energy penalties
if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * elapsedHours;
if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * elapsedHours;
// Happiness penalties
if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * elapsedHours;
if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * elapsedHours;
// Health regeneration (all stats >= 80)
const threshold = ADULT_DECAY.health.regenThreshold;
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
healthDelta += ADULT_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + roundDelta(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
// ─── Main Decay Function ──────────────────────────────────────────────────────
/**
* Apply decay to a Blobbi based on elapsed time since last decay.
*
* This is a pure, deterministic function that:
* 1. Calculates elapsed time from lastDecayAt to now
* 2. Applies stage-specific decay rates
* 3. Truncates all stat deltas toward zero before application (prevents micro-decay from tiny elapsed times)
* 4. Clamps final stats to 1-100 range
* 5. Returns updated stats without side effects
*
* @param input - Decay input parameters from persisted state
* @returns DecayResult with updated stats and new decay timestamp
*/
export function applyBlobbiDecay(input: DecayInput): DecayResult {
const now = input.now ?? Math.floor(Date.now() / 1000);
const lastDecayAt = input.lastDecayAt ?? now;
// Calculate elapsed time
const elapsedSeconds = Math.max(0, now - lastDecayAt);
const elapsedHours = hoursFromSeconds(elapsedSeconds);
// If no time has passed, return current stats unchanged
if (elapsedSeconds === 0) {
return {
stats: {
hunger: getStat(input.stats, 'hunger'),
happiness: getStat(input.stats, 'happiness'),
health: getStat(input.stats, 'health'),
hygiene: getStat(input.stats, 'hygiene'),
energy: getStat(input.stats, 'energy'),
},
elapsedSeconds: 0,
newDecayTimestamp: now,
};
}
// Apply stage-specific decay
let newStats: BlobbiStats;
switch (input.stage) {
case 'egg':
newStats = calculateEggDecay(input.stats, elapsedHours);
break;
case 'baby':
newStats = calculateBabyDecay(input.stats, input.state, elapsedHours);
break;
case 'adult':
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
break;
default:
// Fallback to adult decay for unknown stages
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
}
return {
stats: newStats,
elapsedSeconds,
newDecayTimestamp: now,
};
}
// ─── Threshold Checkers ───────────────────────────────────────────────────────
/**
* Check if a stat is at warning level for the given stage.
*/
export function isStatAtWarning(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): boolean {
const thresholds = WARNING_THRESHOLDS[stage];
const threshold = (thresholds as Record<string, number>)[stat];
if (threshold === undefined) return false;
return value < threshold;
}
/**
* Check if a stat is at critical level for the given stage.
*/
export function isStatAtCritical(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): boolean {
const thresholds = CRITICAL_THRESHOLDS[stage];
const threshold = (thresholds as Record<string, number>)[stat];
if (threshold === undefined) return false;
return value < threshold;
}
/**
* Get the status level for a stat.
* @returns 'critical' | 'warning' | 'normal'
*/
export function getStatStatus(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): 'critical' | 'warning' | 'normal' {
if (isStatAtCritical(stage, stat, value)) return 'critical';
if (isStatAtWarning(stage, stat, value)) return 'warning';
return 'normal';
}
/**
* Get all stats that are at warning or critical level.
*/
export function getStatsNeedingAttention(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> {
const results: Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
// For eggs, only check relevant stats
const relevantStats = stage === 'egg'
? ['health', 'hygiene', 'happiness'] as (keyof BlobbiStats)[]
: statKeys;
for (const stat of relevantStats) {
const value = stats[stat] ?? 100;
const status = getStatStatus(stage, stat, value);
if (status !== 'normal') {
results.push({ stat, value, status });
}
}
return results;
}
// ─── Visible Stats Helper ─────────────────────────────────────────────────────
/**
* Visibility threshold: stats at or above this value are hidden in the UI.
* Only stats below this threshold are displayed.
*/
export const STAT_VISIBILITY_THRESHOLD = 70;
/**
* Get the stats that should be visible for a given stage.
* Eggs only show health, hygiene, happiness.
* Baby/adult show all stats.
*/
export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] {
if (stage === 'egg') {
return ['health', 'hygiene', 'happiness'];
}
return ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
}
/**
* Get visible stats with their values for display.
* Stats at or above STAT_VISIBILITY_THRESHOLD are filtered out.
*/
export function getVisibleStatsWithValues(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): Array<{ stat: keyof BlobbiStats; value: number; status: 'critical' | 'warning' | 'normal' }> {
const visibleStats = getVisibleStats(stage);
return visibleStats
.map(stat => ({
stat,
value: stats[stat] ?? 100,
status: getStatStatus(stage, stat, stats[stat] ?? 100),
}))
.filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD);
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Blobbi → EggGraphic Adapter
*
* This module provides a translation layer between the Blobbi domain model
* and the portable EggGraphic visual module.
*
* PURPOSE:
* - Keep the game/domain visual model decoupled from EggGraphic internals
* - Provide explicit mappings between vocabularies
* - Act as the single translation boundary for visual rendering
*
* USAGE:
* ```ts
* const eggVisual = toEggGraphicVisualBlobbi(companion);
* // Pass eggVisual to EggGraphic component
* ```
*/
import type { EggVisualBlobbi } from '@/blobbi/egg';
import {
type BlobbiCompanion,
type BlobbiPattern,
type BlobbiSpecialMark,
type BlobbiStage,
getTagValue,
} from './blobbi';
// ─── Egg Module Types (derived from EggVisualBlobbi) ──────────────────────────
/** Life stage values accepted by EggGraphic */
type EggLifeStage = NonNullable<EggVisualBlobbi['lifeStage']>;
/** Pattern values accepted by EggGraphic */
type EggPattern = NonNullable<EggVisualBlobbi['pattern']>;
/** Special mark values accepted by EggGraphic */
type EggSpecialMark = NonNullable<EggVisualBlobbi['specialMark']>;
/** Theme variant values accepted by EggGraphic */
type EggThemeVariant = NonNullable<EggVisualBlobbi['themeVariant']>;
// ─── Mapping Tables ───────────────────────────────────────────────────────────
/**
* Maps Blobbi pattern values to EggGraphic pattern values.
* Explicit mapping allows vocabularies to diverge in the future.
*/
const PATTERN_MAP: Record<BlobbiPattern, EggPattern> = {
'solid': 'solid',
'spotted': 'spotted',
'striped': 'striped',
'gradient': 'gradient',
};
/**
* Maps Blobbi special mark values to EggGraphic special mark values.
*/
const SPECIAL_MARK_MAP: Record<BlobbiSpecialMark, EggSpecialMark> = {
'none': 'none',
'star': 'star',
'heart': 'heart',
'sparkle': 'sparkle',
'blush': 'blush',
};
/**
* Maps Blobbi stage values to EggGraphic life stage values.
*/
const LIFE_STAGE_MAP: Record<BlobbiStage, EggLifeStage> = {
'egg': 'egg',
'baby': 'baby',
'adult': 'adult',
};
// ─── Fallback Values ──────────────────────────────────────────────────────────
const DEFAULT_PATTERN: EggPattern = 'solid';
const DEFAULT_SPECIAL_MARK: EggSpecialMark = 'none';
const DEFAULT_LIFE_STAGE: EggLifeStage = 'egg';
const DEFAULT_THEME_VARIANT: EggThemeVariant = 'default';
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Extract crossover app identifier from companion tags.
*/
function extractCrossoverApp(allTags: string[][]): string | undefined {
return getTagValue(allTags, 'crossover_app');
}
// ─── Main Adapter Function ────────────────────────────────────────────────────
/**
* Convert a BlobbiCompanion to EggVisualBlobbi for rendering.
*
* This is the TRANSLATION BOUNDARY between the Blobbi domain model
* and the EggGraphic visual module.
*
* The adapter:
* - Maps vocabulary values through explicit mapping tables
* - Passes through full tags for EggGraphic metadata lookups
* - Provides safe fallbacks for any missing/invalid data
* - Does NOT leak app-specific assumptions into EggGraphic
*
* @param companion - The parsed BlobbiCompanion from parseBlobbiEvent
* @param themeVariant - Optional theme variant override
* @returns Visual data compatible with EggVisualBlobbi
*/
export function toEggGraphicVisualBlobbi(
companion: BlobbiCompanion,
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
): EggVisualBlobbi {
const { visualTraits, stage, allTags } = companion;
return {
// Colors pass through directly (already CSS hex values)
baseColor: visualTraits.baseColor,
secondaryColor: visualTraits.secondaryColor,
// Mapped through explicit tables with fallbacks
pattern: PATTERN_MAP[visualTraits.pattern] ?? DEFAULT_PATTERN,
specialMark: SPECIAL_MARK_MAP[visualTraits.specialMark] ?? DEFAULT_SPECIAL_MARK,
lifeStage: LIFE_STAGE_MAP[stage] ?? DEFAULT_LIFE_STAGE,
// Theme variant
themeVariant,
// Pass through full tags for EggGraphic metadata lookups
tags: allTags,
// Extracted convenience values
crossoverApp: extractCrossoverApp(allTags),
// NOTE: We intentionally do NOT pass companion.name as title here.
// The EggGraphic 'title' field is for special designations (e.g., "Divine"),
// not the pet's name. The pet name is displayed separately by the parent component.
};
}
/**
* Check if two EggVisualBlobbi configurations are visually equivalent.
* Useful for memoization and avoiding unnecessary re-renders.
*/
export function areEggGraphicVisualsEqual(
a: EggVisualBlobbi,
b: EggVisualBlobbi
): boolean {
return (
a.baseColor === b.baseColor &&
a.secondaryColor === b.secondaryColor &&
a.pattern === b.pattern &&
a.specialMark === b.specialMark &&
a.lifeStage === b.lifeStage &&
a.themeVariant === b.themeVariant
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
// src/types/blobbi.ts
/**
* Minimal, clean Blobbi domain types for the new project.
*
* Goal:
* - keep the model small and portable
* - support egg / baby / adult rendering
* - support sleep state
* - support visual customization
* - avoid dragging old project complexity into the new app
*/
/* ────────────────────────────────────────────────────────────────────────── *
* Core lifecycle / state
* ────────────────────────────────────────────────────────────────────────── */
export type BlobbiLifeStage = 'egg' | 'baby' | 'adult';
export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving';
/* ────────────────────────────────────────────────────────────────────────── *
* Visual traits
* ────────────────────────────────────────────────────────────────────────── */
export type BlobbiPattern = 'solid' | 'spotted' | 'striped' | 'gradient';
export type BlobbiSpecialMark = 'none' | 'star' | 'heart' | 'sparkle' | 'blush';
export type BlobbiSize = 'small' | 'medium' | 'large';
export interface BlobbiVisualTraits {
/**
* Main body/base color.
* Example: "#8B5CF6"
*/
baseColor?: string;
/**
* Secondary/accent color, usually used in gradients or details.
*/
secondaryColor?: string;
/**
* Eye / pupil color.
*/
eyeColor?: string;
/**
* Optional pattern used by egg or future visual systems.
*/
pattern?: BlobbiPattern;
/**
* Optional visual mark.
*/
specialMark?: BlobbiSpecialMark;
/**
* Optional size hint for rendering.
*/
size?: BlobbiSize;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Basic stats
* Keep only what is useful right now for UI and simple interactions.
* ────────────────────────────────────────────────────────────────────────── */
export interface BlobbiStats {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Stage-specific fields
* ────────────────────────────────────────────────────────────────────────── */
export interface BlobbiEggData {
incubationTime?: number;
incubationProgress?: number;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface BlobbiBabyData {
// Reserved for future baby-specific fields
}
export interface BlobbiAdultData {
evolutionForm?: string;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Main Blobbi entity
* ────────────────────────────────────────────────────────────────────────── */
export interface Blobbi extends BlobbiVisualTraits {
/**
* Stable unique identifier.
*/
id: string;
/**
* Display name.
*/
name: string;
/**
* Current lifecycle stage.
*/
lifeStage: BlobbiLifeStage;
/**
* Current activity state.
*/
state: BlobbiState;
/**
* Optional convenience boolean for UI code that still expects this.
* Prefer using `state === "sleeping"` in new code.
*/
isSleeping?: boolean;
/**
* Basic gameplay / care stats.
*/
stats: BlobbiStats;
/**
* Ownership / identity metadata.
*/
ownerPubkey?: string;
seed?: string;
/**
* Timestamps.
* Keep them simple for now; decide later whether the project will
* standardize on seconds or milliseconds everywhere.
*/
createdAt?: number;
birthTime?: number;
hatchTime?: number;
lastInteraction?: number;
/**
* Progression.
*/
experience?: number;
generation?: number;
careStreak?: number;
crossoverApp?: string | null;
themeVariant?: string;
/**
* Optional raw tags for Nostr-backed or metadata-driven rendering.
*/
tags?: string[][];
/**
* Optional stage-specific buckets.
* This keeps the root model clean while leaving room to grow.
*/
egg?: BlobbiEggData;
baby?: BlobbiBabyData;
adult?: BlobbiAdultData;
}
/* ────────────────────────────────────────────────────────────────────────── *
* Defaults / helpers
* ────────────────────────────────────────────────────────────────────────── */
export const DEFAULT_BLOBBI_STATS: BlobbiStats = {
hunger: 100,
happiness: 100,
health: 100,
hygiene: 100,
energy: 100,
};
export const DEFAULT_BLOBBI_STATE: BlobbiState = 'active';
export const DEFAULT_BLOBBI_LIFE_STAGE: BlobbiLifeStage = 'egg';
export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
const state = overrides.state ?? DEFAULT_BLOBBI_STATE;
return {
id: overrides.id ?? 'blobbi-1',
name: overrides.name ?? 'Blobbi',
lifeStage: overrides.lifeStage ?? DEFAULT_BLOBBI_LIFE_STAGE,
state,
isSleeping: overrides.isSleeping ?? state === 'sleeping',
stats: overrides.stats ?? { ...DEFAULT_BLOBBI_STATS },
baseColor: overrides.baseColor,
secondaryColor: overrides.secondaryColor,
eyeColor: overrides.eyeColor,
pattern: overrides.pattern,
specialMark: overrides.specialMark,
size: overrides.size,
ownerPubkey: overrides.ownerPubkey,
seed: overrides.seed,
createdAt: overrides.createdAt,
birthTime: overrides.birthTime,
hatchTime: overrides.hatchTime,
lastInteraction: overrides.lastInteraction,
experience: overrides.experience ?? 0,
generation: overrides.generation ?? 1,
careStreak: overrides.careStreak ?? 0,
crossoverApp: overrides.crossoverApp ?? null,
themeVariant: overrides.themeVariant,
tags: overrides.tags ?? [],
egg: overrides.egg,
baby: overrides.baby,
adult: overrides.adult,
};
}
/* ────────────────────────────────────────────────────────────────────────── *
* Type guards
* ────────────────────────────────────────────────────────────────────────── */
export function isEggBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'egg';
}
export function isBabyBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'baby';
}
export function isAdultBlobbi(blobbi: Blobbi): boolean {
return blobbi.lifeStage === 'adult';
}
export function isBlobbiSleeping(blobbi: Blobbi): boolean {
return blobbi.state === 'sleeping' || blobbi.isSleeping === true;
}
+4 -17
View File
@@ -22,7 +22,7 @@ import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbiStage, BlobbiState, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -58,8 +58,6 @@ export interface BlobbiDevUpdates {
breedingReady?: boolean;
/** Generation number */
generation?: number;
/** Visibility to others */
visibleToOthers?: boolean;
}
// ─── Stat Presets ─────────────────────────────────────────────────────────────
@@ -189,7 +187,6 @@ export function BlobbiDevEditor({
const [careStreak, setCareStreak] = useState(companion.careStreak ?? 0);
const [breedingReady, setBreedingReady] = useState(companion.breedingReady);
const [generation, setGeneration] = useState(companion.generation ?? 1);
const [visibleToOthers, setVisibleToOthers] = useState(companion.visibleToOthers);
// Reset state when companion changes or modal opens
const resetToCompanion = useCallback(() => {
@@ -207,7 +204,6 @@ export function BlobbiDevEditor({
setCareStreak(companion.careStreak ?? 0);
setBreedingReady(companion.breedingReady);
setGeneration(companion.generation ?? 1);
setVisibleToOthers(companion.visibleToOthers);
}, [companion]);
// Check if there are any changes
@@ -224,10 +220,9 @@ export function BlobbiDevEditor({
experience !== (companion.experience ?? 0) ||
careStreak !== (companion.careStreak ?? 0) ||
breedingReady !== companion.breedingReady ||
generation !== (companion.generation ?? 1) ||
visibleToOthers !== companion.visibleToOthers
generation !== (companion.generation ?? 1)
);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion]);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion]);
// Apply preset
const applyPreset = useCallback((preset: StatPreset) => {
@@ -270,11 +265,10 @@ export function BlobbiDevEditor({
if (careStreak !== (companion.careStreak ?? 0)) updates.careStreak = careStreak;
if (breedingReady !== companion.breedingReady) updates.breedingReady = breedingReady;
if (generation !== (companion.generation ?? 1)) updates.generation = generation;
if (visibleToOthers !== companion.visibleToOthers) updates.visibleToOthers = visibleToOthers;
await onApply(updates);
onClose();
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion, onApply, onClose]);
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion, onApply, onClose]);
// Handle close
const handleClose = useCallback(() => {
@@ -533,13 +527,6 @@ export function BlobbiDevEditor({
onCheckedChange={setBreedingReady}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-sm">Visible to Others</Label>
<Switch
checked={visibleToOthers}
onCheckedChange={setVisibleToOthers}
/>
</div>
</div>
</div>
</div>
+2
View File
@@ -32,6 +32,8 @@ interface BlobbiEmotionPanelProps {
const EMOTIONS: Array<{ value: BlobbiEmotion; label: string; emoji: string }> = [
{ value: 'neutral', label: 'Default', emoji: '😊' },
{ value: 'sad', label: 'Sad', emoji: '😢' },
{ value: 'boring', label: 'Boring', emoji: '😑' },
{ value: 'dirty', label: 'Dirty', emoji: '💩' },
{ value: 'happy', label: 'Extra Happy', emoji: '😄' },
{ value: 'angry', label: 'Angry', emoji: '😠' },
{ value: 'surprised', label: 'Surprised', emoji: '😲' },
+2 -6
View File
@@ -14,8 +14,8 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbiStage } from '@/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -140,10 +140,6 @@ export function useBlobbiDevUpdate({
tagUpdates.generation = updates.generation.toString();
changedFields.push('generation');
}
if (updates.visibleToOthers !== undefined) {
tagUpdates.visible = updates.visibleToOthers ? 'true' : 'false';
changedFields.push('visible');
}
// Always update last_interaction and last_decay_at
tagUpdates.last_interaction = now.toString();
+507 -5
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useCallback } from 'react';
import type { EggVisualBlobbi } from '../types/egg.types';
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
@@ -12,6 +12,19 @@ import { cn } from '../lib/cn';
*/
export type EggReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
/**
* Status effects for egg-stage visual feedback.
* These are simpler than adult/baby expressions since eggs don't have faces.
*/
export interface EggStatusEffects {
/** Dirty state: shows sweat droplet and dust underneath */
dirty?: boolean;
/** Health state: shows floating dizzy spirals around egg */
sick?: boolean;
/** Happy state: shows subtle sparkles around egg */
happy?: boolean;
}
interface EggGraphicProps {
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
@@ -21,6 +34,42 @@ interface EggGraphicProps {
cracking?: boolean;
warmth?: number; // 0-100, affects the glow (fallback if no blobbi)
forceInlineSvg?: boolean; // New prop to guarantee inline SVG
/** Status effects for egg-stage visual feedback */
statusEffects?: EggStatusEffects;
}
/**
* Create a spiral path for sick/dizzy effects.
* Generates a true Archimedean spiral that starts from center and winds outward.
* Based on the spiral algorithm from eyes/effects.ts.
*
* @param cx - Center X coordinate
* @param cy - Center Y coordinate
* @param radius - Outer radius of the spiral
* @param clockwise - If true, winds clockwise; if false, winds counter-clockwise (default: true)
*/
function createEggSpiralPath(cx: number, cy: number, radius: number, clockwise: boolean = true): string {
const points: string[] = [];
const turns = 2; // Number of complete rotations
const steps = 40; // Smoothness of the spiral
// Direction multiplier: 1 for clockwise, -1 for counter-clockwise
const direction = clockwise ? 1 : -1;
for (let i = 0; i <= steps; i++) {
const angle = direction * (i / steps) * turns * 2 * Math.PI;
const r = (i / steps) * radius;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
points.push(`M ${x.toFixed(2)} ${y.toFixed(2)}`);
} else {
points.push(`L ${x.toFixed(2)} ${y.toFixed(2)}`);
}
}
return points.join(' ');
}
// Legacy fallback function for special marks (kept for compatibility)
@@ -64,6 +113,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
cracking = false,
warmth = 50,
forceInlineSvg: _forceInlineSvg = false,
statusEffects,
}) => {
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
// Parent container controls actual rendered width/height via slot
@@ -98,6 +148,18 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
const scale = fillScale[sizeVariant] || fillScale.medium;
// Tap-to-wiggle interaction state
const [isTapWiggling, setIsTapWiggling] = useState(false);
const handleEggClick = useCallback(() => {
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
setIsTapWiggling(true);
}, [isTapWiggling, cracking]);
const handleWiggleEnd = useCallback(() => {
setIsTapWiggling(false);
}, []);
// Divine color constants
const DIVINE_PRIMARY_GREEN = '#55C4A2';
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
@@ -393,11 +455,17 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
{/* Main egg shape - uses percentage-based sizing */}
<div
onClick={handleEggClick}
onAnimationEnd={(e) => {
if (e.animationName === 'egg-tap-wiggle') handleWiggleEnd();
}}
className={cn(
'relative transition-all duration-500 z-10',
// Reaction-based animations (music/sing)
(reaction === 'listening' || reaction === 'swaying' || reaction === 'happy') && 'animate-egg-sway',
reaction === 'singing' && 'animate-egg-bounce',
'relative transition-all duration-500 z-10 cursor-pointer',
// Tap wiggle (highest priority after cracking)
isTapWiggling && !cracking && 'animate-egg-tap-wiggle',
// Reaction-based animations (music/sing) - only when not tap-wiggling
!isTapWiggling && (reaction === 'listening' || reaction === 'swaying' || reaction === 'happy') && 'animate-egg-sway',
!isTapWiggling && reaction === 'singing' && 'animate-egg-bounce',
// Warmth effect only when animated AND warm
animated && actualWarmth > 60 && 'animate-egg-warmth',
// Cracking overrides other animations
@@ -657,6 +725,440 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
/>
</>
)}
{/* ── Status Effects ─────────────────────────────────────────────── */}
{/* Dirty effect: sweat droplet + dust at lower shell edges
Placement rules for egg:
- Sweat droplet stays at upper-left (outside egg, not on face)
- Dust particles only at lower outer shell edges
- Avoid center-front placement entirely
*/}
{statusEffects?.dirty && (
<>
{/* Sweat droplet - upper left outside egg shell */}
<div
className="absolute animate-egg-sweat-drop"
style={{
top: '15%',
left: '5%',
width: '0.6em',
height: '0.9em',
background: 'linear-gradient(180deg, rgba(147, 197, 253, 0.9) 0%, rgba(59, 130, 246, 0.7) 100%)',
borderRadius: '50% 50% 50% 50% / 30% 30% 70% 70%',
zIndex: 20,
}}
/>
{/* Dust particles underneath (back layer) - at lower edges */}
<div
className="absolute animate-egg-dust"
style={{
bottom: '-5%',
left: '20%',
width: '60%',
height: '0.4em',
display: 'flex',
justifyContent: 'space-between',
zIndex: 5,
}}
>
{/* Left edge particle */}
<div
style={{
width: '0.3em',
height: '0.3em',
background: 'rgba(87, 83, 78, 0.7)',
borderRadius: '50%',
}}
className="animate-egg-dust-particle"
/>
{/* Right edge particle */}
<div
style={{
width: '0.28em',
height: '0.28em',
background: 'rgba(87, 83, 78, 0.65)',
borderRadius: '50%',
animationDelay: '0.4s',
}}
className="animate-egg-dust-particle"
/>
</div>
{/* Front-layer dust - at lower-left and lower-right edges
More visible than back layer, stronger colors */}
{/* Lower-left edge particle - larger, more visible */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '12%',
left: '6%',
width: '0.28em',
height: '0.28em',
background: 'rgba(63, 63, 70, 0.8)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '0.1s',
}}
/>
{/* Lower-right edge particle */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '15%',
right: '5%',
width: '0.25em',
height: '0.25em',
background: 'rgba(63, 63, 70, 0.75)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '0.5s',
}}
/>
{/* Very bottom left particle */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '5%',
left: '18%',
width: '0.22em',
height: '0.22em',
background: 'rgba(68, 64, 60, 0.7)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '0.8s',
}}
/>
{/* Very bottom right particle */}
<div
className="absolute animate-egg-dust-particle"
style={{
bottom: '3%',
right: '20%',
width: '0.2em',
height: '0.2em',
background: 'rgba(68, 64, 60, 0.65)',
borderRadius: '50%',
zIndex: 25,
animationDelay: '1.1s',
}}
/>
</>
)}
{/* Sick effect: layered dizzy spirals around and across egg
Creates a magical/dizzy atmosphere with multiple spiral layers:
- Outer spirals: floating around the egg shell
- Inner spirals: across the egg body itself
- Mixed colors: gray (primary) + white (accents)
- Varying sizes, speeds, and rotation directions
All use true Archimedean spiral paths matching Blobbi dizzy eyes
*/}
{statusEffects?.sick && (
<>
{/* ═══ OUTER SPIRALS (floating around egg) ═══ */}
{/* Outer 1 - top left, large, gray, COUNTER-CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '0%',
left: '-5%',
width: '1.1em',
height: '1.1em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, false)}
stroke="#4b5563"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
opacity="0.65"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="2.5s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Outer 2 - right side, medium, gray, CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '25%',
right: '-6%',
width: '0.95em',
height: '0.95em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, true)}
stroke="#6b7280"
strokeWidth="1.4"
fill="none"
strokeLinecap="round"
opacity="0.6"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="360 10 10"
to="0 10 10"
dur="2s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Outer 3 - bottom left, small, white accent, COUNTER-CLOCKWISE path */}
<svg
className="absolute"
style={{
bottom: '15%',
left: '-4%',
width: '0.7em',
height: '0.7em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, false)}
stroke="white"
strokeWidth="1.3"
fill="none"
strokeLinecap="round"
opacity="0.5"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="360 10 10"
to="0 10 10"
dur="3s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Outer 4 - bottom right, tiny, gray, CLOCKWISE path */}
<svg
className="absolute"
style={{
bottom: '8%',
right: '-3%',
width: '0.6em',
height: '0.6em',
zIndex: 20,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 8, true)}
stroke="#9ca3af"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
opacity="0.5"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="3.5s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* ═══ INNER SPIRALS (across egg body) ═══ */}
{/* Inner 1 - upper egg, small, white, COUNTER-CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '18%',
left: '22%',
width: '0.55em',
height: '0.55em',
zIndex: 15,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 7, false)}
stroke="white"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
opacity="0.4"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="4s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Inner 2 - mid-right egg, tiny, gray, CLOCKWISE path */}
<svg
className="absolute"
style={{
top: '40%',
right: '18%',
width: '0.5em',
height: '0.5em',
zIndex: 15,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 7, true)}
stroke="#6b7280"
strokeWidth="1.1"
fill="none"
strokeLinecap="round"
opacity="0.35"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="360 10 10"
to="0 10 10"
dur="3s"
repeatCount="indefinite"
/>
</g>
</svg>
{/* Inner 3 - lower-center egg, small, white accent */}
<svg
className="absolute"
style={{
bottom: '28%',
left: '35%',
width: '0.45em',
height: '0.45em',
zIndex: 15,
overflow: 'visible',
}}
viewBox="0 0 20 20"
>
<g>
<path
d={createEggSpiralPath(10, 10, 7, false)}
stroke="white"
strokeWidth="1"
fill="none"
strokeLinecap="round"
opacity="0.35"
/>
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="3.5s"
repeatCount="indefinite"
/>
</g>
</svg>
</>
)}
{/* Happy effect: subtle sparkles around egg */}
{statusEffects?.happy && (
<>
{/* Sparkle 1 - top */}
<div
className="absolute animate-egg-sparkle"
style={{
top: '5%',
left: '45%',
width: '0.5em',
height: '0.5em',
zIndex: 20,
}}
>
<svg viewBox="0 0 20 20" className="w-full h-full">
<path
d="M10 0 L10 20 M0 10 L20 10 M3 3 L17 17 M17 3 L3 17"
stroke="rgba(251, 191, 36, 0.8)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
{/* Sparkle 2 - right */}
<div
className="absolute animate-egg-sparkle"
style={{
top: '25%',
right: '0%',
width: '0.4em',
height: '0.4em',
zIndex: 20,
animationDelay: '0.4s',
}}
>
<svg viewBox="0 0 20 20" className="w-full h-full">
<path
d="M10 0 L10 20 M0 10 L20 10"
stroke="rgba(251, 191, 36, 0.7)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
{/* Sparkle 3 - left */}
<div
className="absolute animate-egg-sparkle"
style={{
top: '40%',
left: '0%',
width: '0.35em',
height: '0.35em',
zIndex: 20,
animationDelay: '0.8s',
}}
>
<svg viewBox="0 0 20 20" className="w-full h-full">
<path
d="M10 0 L10 20 M0 10 L20 10"
stroke="rgba(251, 191, 36, 0.6)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
</>
)}
</div>
</div>
);
+1 -1
View File
@@ -12,7 +12,7 @@
import './styles/egg-animations.css';
// Components
export { EggGraphic, type EggReactionState } from './components/EggGraphic';
export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic';
export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer';
// Hooks
+118 -1
View File
@@ -82,6 +82,118 @@
animation: egg-crack-shake 0.5s ease-in-out infinite;
}
/* Tap interaction: rock side-to-side and hop */
@keyframes egg-tap-wiggle {
0% {
transform: translateY(0) rotate(0deg);
}
15% {
transform: translateY(-2px) rotate(-6deg);
}
30% {
transform: translateY(-6px) rotate(5deg);
}
45% {
transform: translateY(-3px) rotate(-4deg);
}
60% {
transform: translateY(-1px) rotate(3deg);
}
75% {
transform: translateY(0) rotate(-1.5deg);
}
90% {
transform: translateY(0) rotate(0.5deg);
}
100% {
transform: translateY(0) rotate(0deg);
}
}
.animate-egg-tap-wiggle {
animation: egg-tap-wiggle 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* ==========================================
Egg Status Effect Animations
========================================== */
/* Sweat droplet - slides down and fades */
@keyframes egg-sweat-drop {
0% {
opacity: 0;
transform: translateY(-2px);
}
20% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(4px);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
.animate-egg-sweat-drop {
animation: egg-sweat-drop 2s ease-in-out infinite;
}
/* Dust particles - gentle float up */
@keyframes egg-dust-particle {
0%, 100% {
opacity: 0.3;
transform: translateY(0);
}
50% {
opacity: 0.6;
transform: translateY(-3px);
}
}
.animate-egg-dust-particle {
animation: egg-dust-particle 2s ease-in-out infinite;
}
/* Dizzy spirals - rotate and float */
@keyframes egg-spiral {
0% {
opacity: 0.4;
transform: rotate(0deg) translateY(0);
}
50% {
opacity: 0.8;
transform: rotate(180deg) translateY(-4px);
}
100% {
opacity: 0.4;
transform: rotate(360deg) translateY(0);
}
}
.animate-egg-spiral {
animation: egg-spiral 3s ease-in-out infinite;
}
/* Happy sparkles - twinkle effect */
@keyframes egg-sparkle {
0%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.animate-egg-sparkle {
animation: egg-sparkle 1.5s ease-in-out infinite;
}
/* ==========================================
Special Mark Animations
========================================== */
@@ -234,7 +346,12 @@
.animate-egg-sway,
.animate-egg-bounce,
.animate-egg-warmth,
.animate-egg-crack {
.animate-egg-crack,
.animate-egg-tap-wiggle,
.animate-egg-sweat-drop,
.animate-egg-dust-particle,
.animate-egg-spiral,
.animate-egg-sparkle {
animation: none !important;
}
}
@@ -16,7 +16,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { BLOBBI_ADOPTION_COST } from '@/lib/blobbi';
import { BLOBBI_ADOPTION_COST } from '@/blobbi/core/lib/blobbi';
import { formatCompactNumber } from '@/lib/utils';
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
@@ -19,7 +19,7 @@ import { cn, formatCompactNumber } from '@/lib/utils';
import {
BLOBBI_PREVIEW_REROLL_COST,
BLOBBI_ADOPTION_COST,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import type { BlobbiEggPreview } from '../lib/blobbi-preview';
import { previewToBlobbiCompanion } from '../lib/blobbi-preview';
@@ -26,7 +26,7 @@ import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
import { Loader2 } from 'lucide-react';
import type { BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
interface BlobbiOnboardingFlowProps {
/** Current profile (null if doesn't exist) */
@@ -31,7 +31,7 @@ import {
buildBlobbonautTags,
updateBlobbonautTags,
type BlobbonautProfile,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import {
generateEggPreview,
+1 -3
View File
@@ -16,7 +16,7 @@ import {
getLocalDayString,
type BlobbiVisualTraits,
type BlobbiStats,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -135,7 +135,6 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] {
['stage', preview.stage],
['state', preview.state],
['seed', preview.seed],
['visible_to_others', 'true'],
['generation', '1'],
['breeding_ready', 'false'],
['experience', '0'],
@@ -181,7 +180,6 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
isLegacy: false,
lastInteraction: preview.createdAt,
lastDecayAt: preview.createdAt,
visibleToOthers: true,
generation: 1,
breedingReady: false,
experience: 0,
@@ -18,7 +18,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { BlobbiCompanion, BlobbonautProfile } from '@/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getShopItemById } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
@@ -45,20 +45,25 @@ interface ResolvedInventoryItem extends ShopItem {
reason?: string;
}
export function BlobbiInventoryModal({
open,
onOpenChange,
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
// State for use confirmation dialog
}: BlobbiInventoryContentProps) {
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showUseDialog, setShowUseDialog] = useState(false);
// Resolve storage items with their metadata and usability from the shop catalog
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
@@ -68,7 +73,6 @@ export function BlobbiInventoryModal({
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
// Check if item can be used for current stage
const usability = canUseItemForStage(storageItem.itemId, stage);
result.push({
@@ -84,7 +88,6 @@ export function BlobbiInventoryModal({
const isEmpty = inventoryItems.length === 0;
// Handlers for use dialog
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
@@ -95,7 +98,6 @@ export function BlobbiInventoryModal({
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset state
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
@@ -109,7 +111,6 @@ export function BlobbiInventoryModal({
}
};
// 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));
@@ -123,142 +124,117 @@ export function BlobbiInventoryModal({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<div className="min-w-0">
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground">
{isEmpty ? 'No items yet' : `${inventoryItems.length} ${inventoryItems.length === 1 ? 'item' : 'items'}`}
</p>
</div>
<>
<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>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
<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>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto 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 to purchase items for your Blobbi. Items you buy will appear here.
</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 + 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>
) : (
<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>
{/* 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 on mobile */}
{/* 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>
{/* 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>
))}
</div>
)}
</div>
</DialogContent>
{/* 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>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
@@ -276,6 +252,49 @@ export function BlobbiInventoryModal({
isUsing={isUsingItem}
/>
)}
</>
);
}
// ── Standalone Inventory Modal (kept for backwards compatibility) ──
export function BlobbiInventoryModal({
open,
onOpenChange,
profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
<Package className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
</div>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogHeader>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto">
<BlobbiInventoryContent
profile={profile}
companion={companion}
onUseItem={onUseItem}
isUsingItem={isUsingItem}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -309,15 +328,12 @@ function InventoryUseConfirmDialog({
onConfirm,
isUsing,
}: InventoryUseConfirmDialogProps) {
// Calculate total effect for the selected quantity by simulating sequential application
// This matches the actual behavior when items are used (clamping at each step)
const totalEffect = useMemo(() => {
if (!item.effect) return null;
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
// Apply effects N times in sequence with clamping at each step
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
@@ -327,7 +343,6 @@ function InventoryUseConfirmDialog({
}
}
// Calculate actual deltas (may be less than effect * quantity due to clamping)
const result: Record<string, number> = {};
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
@@ -12,7 +12,6 @@ import {
import { Input } from '@/components/ui/input';
import type { ShopItem } from '../types/shop.types';
import { ItemEffectDisplay } from './ItemEffectDisplay';
import { formatCompactNumber } from '@/lib/utils';
interface BlobbiPurchaseDialogProps {
@@ -150,13 +149,6 @@ export function BlobbiPurchaseDialog({
)}
</div>
{/* Effects Summary */}
{item.effect && Object.keys(item.effect).length > 0 && (
<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">Effects per item</h4>
<ItemEffectDisplay effect={item.effect} variant="grid" />
</div>
)}
</div>
<DialogFooter>
+321 -135
View File
@@ -1,178 +1,364 @@
import { useState } from 'react';
import { ShoppingBag, Utensils, Gamepad2, Heart, Droplets, Palette, X } from 'lucide-react';
import { useState, useMemo } from 'react';
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { BlobbiShopItemRow } from './BlobbiShopItemRow';
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
import type { BlobbonautProfile } from '@/lib/blobbi';
import { getShopItemsByType } from '../lib/blobbi-shop-items';
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 { 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 */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
interface BlobbiShopModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
profile: BlobbonautProfile | null;
/** Initial tab to open on. Defaults to "items". */
initialTab?: TopTab;
// ── Inventory props (passed through) ──
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
isUsingItem?: boolean;
}
const CATEGORIES: Array<{
type: ShopItemCategory;
label: string;
icon: React.ReactNode;
}> = [
{ type: 'food', label: 'Food', icon: <Utensils className="size-4" /> },
{ type: 'toy', label: 'Toys', icon: <Gamepad2 className="size-4" /> },
{ type: 'medicine', label: 'Medicine', icon: <Heart className="size-4" /> },
{ type: 'hygiene', label: 'Hygiene', icon: <Droplets className="size-4" /> },
{ type: 'accessory', label: 'Accessories', icon: <Palette className="size-4" /> },
];
export function BlobbiShopModal({ open, onOpenChange, profile }: BlobbiShopModalProps) {
const [activeCategory, setActiveCategory] = useState<ShopItemCategory>('food');
const [selectedItem, setSelectedItem] = useState<ShopItem | null>(null);
const [showPurchaseDialog, setShowPurchaseDialog] = useState(false);
export function BlobbiShopModal({
open,
onOpenChange,
profile,
initialTab = 'items',
companion,
onUseItem,
isUsingItem,
}: BlobbiShopModalProps) {
const [topTab, setTopTab] = useState<TopTab>(initialTab);
const { mutate: purchaseItem, isPending: isPurchasing } = useBlobbiPurchaseItem(profile);
const [purchasingItemId, setPurchasingItemId] = useState<string | null>(null);
const availableCoins = profile?.coins ?? 0;
const items = getShopItemsByType(activeCategory);
const allItems = getLiveShopItems();
const handlePurchaseClick = (item: ShopItem) => {
setSelectedItem(item);
setShowPurchaseDialog(true);
// Reset to initialTab when modal re-opens
const handleOpenChange = (isOpen: boolean) => {
if (isOpen) {
setTopTab(initialTab);
}
onOpenChange(isOpen);
};
const handlePurchase = (quantity: number) => {
if (!selectedItem) return;
// Instant purchase — one tap = one item
const handleBuyItem = (item: ShopItem) => {
if (isPurchasing || availableCoins < item.price) return;
setPurchasingItemId(item.id);
purchaseItem(
{
itemId: selectedItem.id,
price: selectedItem.price,
quantity,
},
{
onSuccess: () => {
setShowPurchaseDialog(false);
setSelectedItem(null);
},
}
{ itemId: item.id, price: item.price, quantity: 1 },
{ onSettled: () => setPurchasingItemId(null) },
);
};
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
// ── Inventory items resolution ──
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
// ── Inventory use item handler ──
const [usingItemId, setUsingItemId] = useState<string | null>(null);
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
setUsingItemId(item.itemId);
onUseItem(item.itemId, 1);
};
// Clear usingItemId when isUsingItem goes false
const effectiveUsingItemId = isUsingItem ? usingItemId : null;
const inventoryEmpty = inventoryItems.length === 0;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
{/* Header - Sticky */}
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center shrink-0">
<ShoppingBag className="size-4 sm:size-5 text-primary" />
</div>
<DialogTitle className="text-xl sm:text-2xl truncate">Blobbi Shop</DialogTitle>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-sm sm:text-base px-3 sm:px-4 py-1">
{formatCompactNumber(availableCoins)} coins
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden rounded-2xl [&>button:last-child]:hidden">
{/* Tab Bar (replaces header) */}
<div className="flex items-center border-b bg-muted/30">
{/* Tabs */}
<button
onClick={() => setTopTab('items')}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
topTab === 'items'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground/70'
)}
>
<Package className="size-4" />
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)}
</Badge>
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
</DialogHeader>
)}
{topTab === 'items' && (
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
)}
</button>
<button
onClick={() => setTopTab('shop')}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
topTab === 'shop'
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground/70'
)}
>
<ShoppingBag className="size-4" />
Shop
{topTab === 'shop' && (
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
)}
</button>
{/* Category Tabs - Part of sticky header area */}
<div className="sticky top-[60px] sm:top-[72px] z-10 bg-background px-4 sm:px-6 pt-3 sm:pt-4 pb-2 border-b">
<div className="flex gap-1.5 sm:gap-2 overflow-x-auto pb-1 -mx-1 px-1">
{CATEGORIES.map(category => {
const isActive = activeCategory === category.type;
const itemCount = getShopItemsByType(category.type).length;
return (
<button
key={category.type}
onClick={() => setActiveCategory(category.type)}
className={cn(
'flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg transition-all whitespace-nowrap',
'border text-sm sm:text-base',
isActive
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/50 text-muted-foreground border-transparent hover:bg-muted'
)}
>
{category.icon}
<span className="font-medium hidden xs:inline">{category.label}</span>
<Badge variant="secondary" className="ml-0.5 sm:ml-1 text-xs">
{itemCount}
</Badge>
</button>
);
})}
{/* Coin badge + Close */}
<div className="flex items-center gap-1.5 pr-3 pl-2">
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-xs px-2 py-0.5">
<span className="mr-1">🪙</span>{formatCompactNumber(availableCoins)}
</Badge>
<DialogClose className="rounded-full p-1 opacity-60 hover:opacity-100 transition-opacity">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
{/* Scrollable Content Area */}
{/* Scrollable Content */}
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Accessories Coming Soon Banner */}
{activeCategory === 'accessory' && (
<div className="mx-4 sm:mx-6 mt-3 sm:mt-4 p-4 sm:p-6 rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
<div className="flex items-start gap-3 sm:gap-4">
<div className="size-12 sm:size-16 rounded-xl sm:rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-2xl sm:text-3xl relative shrink-0">
🎨
<div className="absolute -top-1 -right-1 text-base sm:text-xl"></div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold mb-1">Accessories Coming Soon!</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Get ready to customize your Blobbi's appearance with amazing accessories and cosmetic items.
</p>
</div>
</div>
</div>
{topTab === 'shop' ? (
<ShopGrid
items={allItems}
availableCoins={availableCoins}
onBuy={handleBuyItem}
purchasingItemId={effectivePurchasingId}
/>
) : (
<ItemsGrid
items={inventoryItems}
onUseItem={handleUseItem}
isUsingItem={isUsingItem}
usingItemId={effectiveUsingItemId}
onGoToShop={() => setTopTab('shop')}
/>
)}
{/* Items List */}
<div className="px-4 sm:px-6 py-3 sm:py-4">
<div className="space-y-2">
{items.map(item => (
<BlobbiShopItemRow
key={item.id}
item={item}
availableCoins={availableCoins}
onPurchaseClick={handlePurchaseClick}
/>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Purchase Dialog */}
{selectedItem && (
<BlobbiPurchaseDialog
open={showPurchaseDialog}
onOpenChange={setShowPurchaseDialog}
item={selectedItem}
availableCoins={availableCoins}
onPurchase={handlePurchase}
isPurchasing={isPurchasing}
/>
)}
</>
);
}
// ─── Shop Grid (tile layout, all items, cost in button) ───────────────────────
interface ShopGridProps {
items: ShopItem[];
availableCoins: number;
onBuy: (item: ShopItem) => void;
purchasingItemId: string | null;
}
function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridProps) {
return (
<div className="p-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isDisabled = item.status === 'disabled';
const isAffordable = !isDisabled && availableCoins >= item.price;
const isBuying = purchasingItemId === item.id;
return (
<div
key={item.id}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center',
'bg-card/60 backdrop-blur-sm',
isDisabled && 'opacity-50',
!isDisabled && !isAffordable && 'opacity-70',
)}
>
{/* Icon */}
<div className="text-3xl leading-none mt-1">{item.icon}</div>
{/* Name */}
<span className="text-xs font-medium truncate w-full">{item.name}</span>
{/* Buy button with integrated cost */}
<button
onClick={() => onBuy(item)}
disabled={isDisabled || !isAffordable || !!purchasingItemId}
className={cn(
'w-full rounded-lg px-2 py-1.5 text-xs font-medium transition-colors',
isDisabled
? 'bg-muted text-muted-foreground cursor-not-allowed'
: isAffordable
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 active:scale-95 transition-transform'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{isDisabled ? (
'Soon'
) : isBuying ? (
<span className="flex items-center justify-center">
<Loader2 className="size-3 animate-spin" />
</span>
) : (
<span className="flex items-center justify-center gap-1">
<span>🪙</span> {formatCompactNumber(item.price)}
</span>
)}
</button>
</div>
);
})}
</div>
</div>
);
}
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
interface ItemsGridProps {
items: ResolvedInventoryItem[];
onUseItem: (item: ResolvedInventoryItem) => void;
isUsingItem?: boolean;
usingItemId: string | null;
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, 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>
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
<ShoppingBag className="size-3.5" />
Browse Shop
</Button>
</div>
);
}
return (
<div className="p-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{items.map(item => {
const isThisUsing = isUsingItem && usingItemId === item.itemId;
return (
<div
key={item.itemId}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center relative',
'bg-card/60 backdrop-blur-sm',
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>
{/* Name */}
<span className="text-xs font-medium truncate w-full">{item.name}</span>
{/* Use button */}
{item.canUse ? (
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
onClick={() => onUseItem(item)}
disabled={isUsingItem}
>
{isThisUsing ? (
<Loader2 className="size-3 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="w-full">
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
disabled
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -5,12 +5,12 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { PurchaseRequest } from '../types/shop.types';
import type { BlobbonautProfile, StorageItem } from '@/lib/blobbi';
import type { BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
createStorageTags,
} from '@/lib/blobbi';
} from '@/blobbi/core/lib/blobbi';
import { getShopItemById } from '../lib/blobbi-shop-items';
/**
+1 -34
View File
@@ -177,39 +177,6 @@ export const BLOBBI_SHOP_ITEMS: ShopItem[] = [
status: 'live',
},
// ─── Accessory Items (Disabled) ─────────────────────────────────────────────
{
id: 'acc_hat',
name: 'Party Hat',
type: 'accessory',
price: 75,
icon: '🎩',
status: 'disabled',
},
{
id: 'acc_glasses',
name: 'Cool Glasses',
type: 'accessory',
price: 60,
icon: '🕶️',
status: 'disabled',
},
{
id: 'acc_bow',
name: 'Bow Tie',
type: 'accessory',
price: 50,
icon: '🎀',
status: 'disabled',
},
{
id: 'acc_crown',
name: 'Crown',
type: 'accessory',
price: 100,
icon: '👑',
status: 'disabled',
},
];
/**
@@ -237,7 +204,7 @@ export function getLiveShopItems(): ShopItem[] {
* Get all shop item categories with their counts
*/
export function getShopCategories(): Array<{ type: ShopItemCategory; count: number; label: string }> {
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene', 'accessory'];
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene'];
return categories.map(type => ({
type,
+1 -2
View File
@@ -7,8 +7,7 @@ export type ShopItemCategory =
| 'food'
| 'toy'
| 'medicine'
| 'hygiene'
| 'accessory';
| 'hygiene';
/**
* Stat effects that items can apply to Blobbi
+104
View File
@@ -0,0 +1,104 @@
/**
* BlobbiAdultSvgRenderer — Pure SVG rendering component for adult Blobbi.
*
* This component is the leaf node of the visual pipeline. It:
* 1. Resolves the base SVG for the adult form
* 2. Customizes colors and unique IDs
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
* 4. Applies visual recipe or emotion preset
* 5. Applies manual body effects (when no recipe is provided)
* 6. Sanitizes the SVG
* 7. Renders via dangerouslySetInnerHTML
*
* It does NOT know about:
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
* - Render mode (page vs companion)
* - Reaction CSS classes (sway / bounce)
* - Companion runtime (drag, float, position)
*
* This separation ensures that the SVG DOM node stays mounted and stable
* as long as the visual inputs don't change. SMIL and CSS animations
* inside the SVG continue running across parent rerenders.
*/
import { useMemo } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import { addEyeAnimation } from './lib/eye-animation';
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
import { debugBlobbi } from './lib/debug';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
export interface BlobbiAdultSvgRendererProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Whether the Blobbi is sleeping */
isSleeping: boolean;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (used in CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
emotion?: BlobbiEmotion;
/** Body-level visual effects (manual/external use only — not from status reaction). */
bodyEffects?: BodyEffectsSpec;
/** Additional CSS classes for the container */
className?: string;
}
/**
* Pure SVG renderer for adult Blobbi.
*
* IMPORTANT: This component must remain a pure rendering leaf. It must NOT:
* - Run eye-tracking hooks (those belong in the Visual wrapper)
* - Know about render modes or companion runtime
* - Apply reaction CSS classes (those belong on an outer wrapper)
*
* The parent Visual wrapper owns the DOM query boundary (containerRef)
* that eye hooks use to find SVG elements via querySelector.
*/
export function BlobbiAdultSvgRenderer({
blobbi,
isSleeping: _isSleeping,
recipe: recipeProp,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiAdultSvgRendererProps) {
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild');
// Always use the base (awake) SVG — sleeping is a recipe overlay, not an asset swap
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping: false });
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, false);
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
if (recipeProp) {
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'adult', form, blobbi.id);
} else if (emotion !== 'neutral') {
const resolved = resolveVisualRecipe(emotion);
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'adult', form, blobbi.id);
}
if (bodyEffects && !recipeProp) {
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
}
return animatedSvg;
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
);
}
+87 -115
View File
@@ -1,162 +1,134 @@
/**
* BlobbiAdultVisual - Reusable component for rendering Blobbi adults
* BlobbiAdultVisual — Visual wrapper for rendering Blobbi adults.
*
* Uses the adult-blobbi module for SVG resolution and customization.
* Handles awake vs sleeping states automatically.
* Supports multiple adult evolution forms.
* Eyes always track the mouse cursor in real-time.
* Responsibilities:
* - Owns the container ref for eye hooks to query SVG DOM
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
* - Runs useExternalEyeOffset (companion gaze RAF loop)
* - Applies reaction CSS classes (sway/bounce) in page mode
* - Delegates SVG rendering to BlobbiAdultSvgRenderer
*
* The SVG renderer is a separate component so the dangerouslySetInnerHTML
* node stays mounted even when wrapper-level props change (reaction,
* className toggles, etc.).
*
* Render modes:
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
* suppressed (applied by outer companion wrapper instead).
*/
import { useMemo, useRef, useEffect } from 'react';
import { useRef, type RefObject } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
import { cn } from '@/lib/utils';
import { addEyeAnimation } from './lib/eye-animation';
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import type { Blobbi } from '@/types/blobbi';
import { isBlobbiSleeping } from '@/types/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Reaction states for adult Blobbi animations
*/
export type AdultReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
/**
* External eye offset for companion control
* Values range from -1 to 1, converted to pixel movement internally
*/
export interface ExternalEyeOffset {
x: number;
y: number;
}
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
import type { BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import type { BodyEffectsSpec } from './lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
import { BlobbiAdultSvgRenderer } from './BlobbiAdultSvgRenderer';
export interface BlobbiAdultVisualProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Reaction state for music/sing animations */
reaction?: AdultReactionState;
reaction?: BlobbiReactionState;
/** Controls eye tracking behavior (default: 'follow-pointer') */
lookMode?: BlobbiLookMode;
/** Disable blinking animation (for photo/export mode) */
disableBlink?: boolean;
/**
* External eye offset from companion system.
* When provided, bypasses internal mouse tracking and uses this offset directly.
* Values should be -1 to 1, will be converted to pixel movement.
*/
/** External eye offset (value-based — causes rerenders). */
externalEyeOffset?: ExternalEyeOffset;
/**
* Emotional state to display.
* Adds visual overlays like eyebrows, modified mouth, and tears.
* Default: 'neutral' (no modifications)
*/
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
/** Render mode. Default: 'page'. */
renderMode?: BlobbiRenderMode;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (used in CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
emotion?: BlobbiEmotion;
/** Body-level visual effects (manual/external use only). */
bodyEffects?: BodyEffectsSpec;
/** Additional CSS classes for the container */
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
/**
* Renders an adult Blobbi using inline SVG.
*
* - Resolves the correct form from blobbi data (evolutionForm or seed-derived)
* - Selects the correct SVG variant (awake or sleeping) based on state
* - Applies color customization from Blobbi traits
* - Eyes always track the mouse cursor (instant, real-time)
* - Renders safely using dangerouslySetInnerHTML
*/
export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiAdultVisualProps) {
export function BlobbiAdultVisual({
blobbi,
reaction = 'idle',
lookMode = 'follow-pointer',
disableBlink = false,
externalEyeOffset,
externalEyeOffsetRef,
renderMode = 'page',
recipe,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiAdultVisualProps) {
const isSleeping = isBlobbiSleeping(blobbi);
// This ref is the DOM query boundary for eye hooks. useBlobbiEyes and
// useExternalEyeOffset use querySelector on this element to find SVG
// eye elements rendered by the child SvgRenderer.
const containerRef = useRef<HTMLDivElement>(null);
// Disable reactions when sleeping
const isCompanion = renderMode === 'companion';
const effectiveReaction = isSleeping ? 'idle' : reaction;
// Eye animation hook - handles DOM manipulation internally
// When externalEyeOffset is provided, we disable tracking but keep blinking
// ── Eye hooks ──────────────────────────────────────────────────────────────
useBlobbiEyes(containerRef, {
isSleeping,
maxMovement: 2.5, // Slightly more movement for larger adult form
maxMovement: 2.5,
lookMode,
disableBlink,
disableTracking: !!externalEyeOffset, // External system controls eye position
disableTracking: isCompanion,
});
// External eye offset control - applies offset directly when provided
// This bypasses useBlobbiEyes and gives companion full control
useEffect(() => {
if (!externalEyeOffset || !containerRef.current || isSleeping) return;
useExternalEyeOffset({
containerRef,
externalEyeOffset,
externalEyeOffsetRef,
isSleeping,
variant: 'adult',
});
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>('.blobbi-eye-left, .blobbi-eye-right');
if (eyeElements.length === 0) return;
// Convert -1 to 1 offset to pixel movement
// Increased max movement for more visible eye tracking (4.5px horizontal for adults)
const maxMovementX = 4.5;
const x = externalEyeOffset.x * maxMovementX;
// Asymmetric vertical movement:
// - Upward (negative y): stronger movement (1.0x) for clear "looking up" effect
// - Downward (positive y): reduced movement (0.6x) to avoid looking too droopy
// Y offset: -1 = looking up, +1 = looking down
const maxMovementYUp = 4.5; // Full range for looking up
const maxMovementYDown = 2.7; // Reduced range for looking down (0.6x)
const y = externalEyeOffset.y < 0
? externalEyeOffset.y * maxMovementYUp // Looking up: full range
: externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range
eyeElements.forEach(el => {
el.setAttribute('transform', `translate(${x} ${y})`);
});
}, [externalEyeOffset, isSleeping]);
// Memoize the customized SVG to avoid unnecessary processing
const customizedSvg = useMemo(() => {
// Get form and base SVG
const { form, svg } = resolveAdultSvgWithForm(blobbi, { isSleeping });
// Apply color customization
const colorizedSvg = customizeAdultSvgFromBlobbi(svg, form, blobbi, isSleeping);
// Add eye animation wrappers when awake (eyes are closed when sleeping)
if (!isSleeping) {
// Pass base color for eyelid generation
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
// Apply emotion overlays (eyebrows, sad mouth, tears, etc.)
// Pass form for form-specific adjustments (e.g., owli/froggi eyebrow positioning)
if (emotion !== 'neutral') {
animatedSvg = applyEmotion(animatedSvg, emotion, 'adult', form);
}
return animatedSvg;
}
return colorizedSvg;
}, [blobbi, isSleeping, emotion]);
// ── Render ─────────────────────────────────────────────────────────────────
// In companion mode, reaction classes are applied by an outer wrapper to
// keep the dangerouslySetInnerHTML div className-stable.
return (
<div
ref={containerRef}
className={cn(
'relative flex items-center justify-center',
// Reduced opacity when sleeping for visual feedback
isSleeping && 'opacity-70',
// Reaction animations for adult
(effectiveReaction === 'listening' ||
// No opacity change for sleeping — sleeping is a recipe overlay, not a visual dim
!isCompanion && (effectiveReaction === 'listening' ||
effectiveReaction === 'swaying' ||
effectiveReaction === 'happy') &&
'animate-blobbi-sway',
effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className,
)}
// Safe: SVG content comes from our own trusted module
dangerouslySetInnerHTML={{ __html: customizedSvg }}
/>
>
<BlobbiAdultSvgRenderer
blobbi={blobbi}
isSleeping={isSleeping}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* BlobbiBabySvgRenderer — Pure SVG rendering component for baby Blobbi.
*
* This component is the leaf node of the visual pipeline. It:
* 1. Resolves the base SVG for the baby
* 2. Customizes colors and unique IDs
* 3. Adds eye animation infrastructure (blink clip-paths, gaze groups)
* 4. Applies visual recipe or emotion preset
* 5. Applies manual body effects (when no recipe is provided)
* 6. Sanitizes the SVG
* 7. Renders via dangerouslySetInnerHTML
*
* It does NOT know about:
* - Eye tracking hooks (useBlobbiEyes / useExternalEyeOffset)
* - Render mode (page vs companion)
* - Reaction CSS classes (sway / bounce)
* - Companion runtime (drag, float, position)
*/
import { useMemo } from 'react';
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
import { addEyeAnimation } from './lib/eye-animation';
import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects';
import { debugBlobbi } from './lib/debug';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
export interface BlobbiBabySvgRendererProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Whether the Blobbi is sleeping */
isSleeping: boolean;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (used in CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. Default: 'neutral' */
emotion?: BlobbiEmotion;
/** Body-level visual effects (manual/external use only — not from status reaction). */
bodyEffects?: BodyEffectsSpec;
/** Additional CSS classes for the container */
className?: string;
}
/**
* Pure SVG renderer for baby Blobbi.
*
* IMPORTANT: This component must remain a pure rendering leaf. It must NOT:
* - Run eye-tracking hooks (those belong in the Visual wrapper)
* - Know about render modes or companion runtime
* - Apply reaction CSS classes (those belong on an outer wrapper)
*
* The parent Visual wrapper owns the DOM query boundary (containerRef)
* that eye hooks use to find SVG elements via querySelector.
*/
export function BlobbiBabySvgRenderer({
blobbi,
isSleeping: _isSleeping,
recipe: recipeProp,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiBabySvgRendererProps) {
const customizedSvg = useMemo(() => {
debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild');
// Always use the base (awake) SVG — sleeping is a recipe overlay, not an asset swap
const baseSvg = resolveBabySvg(blobbi, { isSleeping: false });
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, false);
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
if (recipeProp) {
animatedSvg = applyVisualRecipe(animatedSvg, recipeProp, recipeLabel ?? 'status', 'baby', undefined, blobbi.id);
} else if (emotion !== 'neutral') {
const resolved = resolveVisualRecipe(emotion);
animatedSvg = applyVisualRecipe(animatedSvg, resolved, emotion, 'baby', undefined, blobbi.id);
}
if (bodyEffects && !recipeProp) {
animatedSvg = applyBodyEffects(animatedSvg, { ...bodyEffects, idPrefix: bodyEffects.idPrefix ?? blobbi.id });
}
return animatedSvg;
}, [blobbi, recipeProp, recipeLabel, emotion, bodyEffects]);
const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]);
return (
<div
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
);
}
+78 -114
View File
@@ -1,156 +1,120 @@
/**
* BlobbiBabyVisual - Reusable component for rendering Blobbi babies
* BlobbiBabyVisual — Visual wrapper for rendering Blobbi babies.
*
* Uses the baby-blobbi module for SVG resolution and customization.
* Handles awake vs sleeping states automatically.
* Eyes always track the mouse cursor in real-time.
* Responsibilities:
* - Owns the container ref for eye hooks to query SVG DOM
* - Runs useBlobbiEyes (blink RAF loop, optional mouse tracking)
* - Runs useExternalEyeOffset (companion gaze RAF loop)
* - Applies reaction CSS classes (sway/bounce) in page mode
* - Delegates SVG rendering to BlobbiBabySvgRenderer
*
* Render modes:
* - 'page' (default): Mouse tracking enabled, reaction classes applied here.
* - 'companion': Mouse tracking disabled (gaze via ref), reaction classes
* suppressed (applied by outer companion wrapper instead).
*/
import { useMemo, useRef, useEffect } from 'react';
import { useRef, type RefObject } from 'react';
import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi';
import { addEyeAnimation } from './lib/eye-animation';
import { applyEmotion, type BlobbiEmotion } from './lib/emotions';
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import { cn } from '@/lib/utils';
import type { Blobbi } from '@/types/blobbi';
import { isBlobbiSleeping } from '@/types/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Reaction states for baby Blobbi animations
*/
export type BabyReactionState = 'idle' | 'listening' | 'swaying' | 'singing' | 'happy';
/**
* External eye offset for companion control
* Values range from -1 to 1, converted to pixel movement internally
*/
export interface ExternalEyeOffset {
x: number;
y: number;
}
import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes';
import { useExternalEyeOffset } from './lib/useExternalEyeOffset';
import type { ExternalEyeOffset, BlobbiReactionState, BlobbiRenderMode } from './lib/types';
import type { BlobbiVisualRecipe } from './lib/recipe';
import type { BlobbiEmotion } from './lib/emotion-types';
import type { BodyEffectsSpec } from './lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import { isBlobbiSleeping } from '@/blobbi/core/types/blobbi';
import { BlobbiBabySvgRenderer } from './BlobbiBabySvgRenderer';
export interface BlobbiBabyVisualProps {
/** The Blobbi data */
blobbi: Blobbi;
/** Reaction state for music/sing animations */
reaction?: BabyReactionState;
/** Controls eye tracking behavior (default: 'follow-pointer') */
reaction?: BlobbiReactionState;
lookMode?: BlobbiLookMode;
/** Disable blinking animation (for photo/export mode) */
disableBlink?: boolean;
/**
* External eye offset from companion system.
* When provided, bypasses internal mouse tracking and uses this offset directly.
* Values should be -1 to 1, will be converted to pixel movement.
*/
externalEyeOffset?: ExternalEyeOffset;
/**
* Emotional state to display.
* Adds visual overlays like eyebrows, modified mouth, and tears.
* Default: 'neutral' (no modifications)
*/
/** Ref-based external eye offset (imperative — no rerenders). Preferred for companion mode. */
externalEyeOffsetRef?: RefObject<ExternalEyeOffset>;
/** Render mode. Default: 'page'. */
renderMode?: BlobbiRenderMode;
/** Pre-resolved visual recipe. Takes precedence over `emotion`. */
recipe?: BlobbiVisualRecipe;
/** Label for the recipe (CSS class names). */
recipeLabel?: string;
/** Named emotion preset. Ignored when `recipe` is provided. */
emotion?: BlobbiEmotion;
/** Additional CSS classes for the container */
/** Body-level visual effects — for manual/external use only. */
bodyEffects?: BodyEffectsSpec;
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
/**
* Renders a baby Blobbi using inline SVG.
*
* - Resolves the correct SVG (awake or sleeping) based on state
* - Applies color customization from Blobbi traits
* - Eyes always track the mouse cursor (instant, real-time)
* - Renders safely using dangerouslySetInnerHTML
*/
export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', disableBlink = false, externalEyeOffset, emotion = 'neutral', className }: BlobbiBabyVisualProps) {
export function BlobbiBabyVisual({
blobbi,
reaction = 'idle',
lookMode = 'follow-pointer',
disableBlink = false,
externalEyeOffset,
externalEyeOffsetRef,
renderMode = 'page',
recipe,
recipeLabel,
emotion = 'neutral',
bodyEffects,
className,
}: BlobbiBabyVisualProps) {
const isSleeping = isBlobbiSleeping(blobbi);
// DOM query boundary for eye hooks. See BlobbiAdultVisual for details.
const containerRef = useRef<HTMLDivElement>(null);
// Disable reactions when sleeping
const isCompanion = renderMode === 'companion';
const effectiveReaction = isSleeping ? 'idle' : reaction;
// Eye animation hook - handles DOM manipulation internally
// When externalEyeOffset is provided, we disable tracking but keep blinking
// ── Eye hooks ──────────────────────────────────────────────────────────────
useBlobbiEyes(containerRef, {
isSleeping,
maxMovement: 2,
lookMode,
disableBlink,
disableTracking: !!externalEyeOffset, // External system controls eye position
disableTracking: isCompanion,
});
// External eye offset control - applies offset directly when provided
// This bypasses useBlobbiEyes and gives companion full control
useEffect(() => {
if (!externalEyeOffset || !containerRef.current || isSleeping) return;
useExternalEyeOffset({
containerRef,
externalEyeOffset,
externalEyeOffsetRef,
isSleeping,
variant: 'baby',
});
const eyeElements = containerRef.current.querySelectorAll<SVGGElement>('.blobbi-eye-left, .blobbi-eye-right');
if (eyeElements.length === 0) return;
// Convert -1 to 1 offset to pixel movement
// Increased max movement for more visible eye tracking (4px horizontal)
const maxMovementX = 4;
const x = externalEyeOffset.x * maxMovementX;
// Asymmetric vertical movement:
// - Upward (negative y): stronger movement (1.0x) for clear "looking up" effect
// - Downward (positive y): reduced movement (0.6x) to avoid looking too droopy
// Y offset: -1 = looking up, +1 = looking down
const maxMovementYUp = 4; // Full range for looking up
const maxMovementYDown = 2.4; // Reduced range for looking down (0.6x)
const y = externalEyeOffset.y < 0
? externalEyeOffset.y * maxMovementYUp // Looking up: full range
: externalEyeOffset.y * maxMovementYDown; // Looking down: reduced range
eyeElements.forEach(el => {
el.setAttribute('transform', `translate(${x} ${y})`);
});
}, [externalEyeOffset, isSleeping]);
// Memoize the customized SVG to avoid unnecessary processing
const customizedSvg = useMemo(() => {
const baseSvg = resolveBabySvg(blobbi, { isSleeping });
const colorizedSvg = customizeBabySvgFromBlobbi(baseSvg, blobbi, isSleeping);
// Add eye animation wrappers (only when not sleeping)
if (!isSleeping) {
// Pass base color for eyelid generation
let animatedSvg = addEyeAnimation(colorizedSvg, { baseColor: blobbi.baseColor, instanceId: blobbi.id });
// Apply emotion overlays (eyebrows, sad mouth, tears, etc.)
// Pass 'baby' variant for baby-specific adjustments (e.g., eyebrow positioning)
if (emotion !== 'neutral') {
animatedSvg = applyEmotion(animatedSvg, emotion, 'baby');
}
return animatedSvg;
}
return colorizedSvg;
}, [blobbi, isSleeping, emotion]);
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div
ref={containerRef}
className={cn(
'relative flex items-center justify-center',
// Reduced opacity when sleeping for visual feedback
isSleeping && 'opacity-70',
// Reaction animations for baby
(effectiveReaction === 'listening' ||
// No opacity change for sleeping — sleeping is a recipe overlay, not a visual dim
!isCompanion && (effectiveReaction === 'listening' ||
effectiveReaction === 'swaying' ||
effectiveReaction === 'happy') &&
'animate-blobbi-sway',
effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className
!isCompanion && effectiveReaction === 'singing' && 'animate-blobbi-bounce',
className,
)}
// Safe: SVG content comes from our own trusted module
dangerouslySetInnerHTML={{ __html: customizedSvg }}
/>
>
<BlobbiBabySvgRenderer
blobbi={blobbi}
isSleeping={isSleeping}
recipe={recipe}
recipeLabel={recipeLabel}
emotion={emotion}
bodyEffects={bodyEffects}
className="size-full"
/>
</div>
);
}
+8 -4
View File
@@ -13,17 +13,17 @@
import { useMemo } from 'react';
import { EggGraphic, type EggReactionState } from '@/blobbi/egg';
import { toEggGraphicVisualBlobbi } from '@/lib/blobbi-egg-adapter';
import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg';
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/lib/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
export type BlobbiEggSize = 'sm' | 'md' | 'lg';
// Re-export for convenience
export type { EggReactionState } from '@/blobbi/egg';
export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg';
export interface BlobbiEggVisualProps {
/** The Blobbi companion data from parseBlobbiEvent */
@@ -34,6 +34,8 @@ export interface BlobbiEggVisualProps {
animated?: boolean;
/** Reaction state for music/sing animations */
reaction?: EggReactionState;
/** Status effects for egg visual feedback (dirty, sick, happy) */
statusEffects?: EggStatusEffects;
/** Additional CSS classes for the container */
className?: string;
}
@@ -67,6 +69,7 @@ export function BlobbiEggVisual({
size = 'md',
animated = false,
reaction = 'idle',
statusEffects,
className,
}: BlobbiEggVisualProps) {
// Memoize adapter output to avoid unnecessary re-renders
@@ -99,6 +102,7 @@ export function BlobbiEggVisual({
sizeVariant={config.sizeVariant}
animated={animated && !isSleeping}
reaction={effectiveReaction}
statusEffects={isSleeping ? undefined : statusEffects}
/>
</div>
);

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