Compare commits

...

875 Commits

Author SHA1 Message Date
Alex Gleason 1a106545f7 Fix haptics: call isNativePlatform() at invocation time, log errors
The platform check was cached as a module-level constant, which could
evaluate before the Capacitor bridge was ready. Moved to per-call checks
matching the pattern used everywhere else in the codebase. Also replaced
silent .catch(() => {}) with console.warn so failures are visible in
Safari Web Inspector / Xcode console.
2026-04-14 14:08:32 -05:00
Alex Gleason ffa1094f93 Add haptic feedback to Blobbi egg interactions
Hatching ceremony: escalating haptics on each crack click (light → medium
→ heavy → success notification on hatch). Egg tap-to-wiggle in feeds and
posts: light impact on each user-initiated tap. Auto-wiggle intervals are
excluded to avoid unwanted vibration.
2026-04-14 11:44:45 -05:00
Alex Gleason e890e913f5 Fix deep-linking on Google Play version (assetlinks.json update) 2026-04-14 11:42:40 -05:00
Alex Gleason 12a4966b84 Add haptic feedback to emoji reaction selection in QuickReactMenu
The popover emoji picker (both quick presets and full picker) was
publishing reactions internally without triggering haptic feedback.
Add impactLight() at the top of publishReaction() so every emoji
selection path gets tactile feedback.
2026-04-14 11:29:22 -05:00
Alex Gleason cc702027b0 Add native haptic feedback to all key interactions
Install @capacitor/haptics and add a centralized haptics utility
(src/lib/haptics.ts) that uses the native taptic engine on iOS/Android
and falls back to navigator.vibrate() on web.

Haptics added to:
- Switch component (covers 36+ toggle switches app-wide)
- PullToRefresh threshold (covers 15+ pages)
- MobileBottomNav tab taps
- ReactionButton (like/unlike, double-click heart)
- RepostMenu (repost/undo repost)
- ZapDialog button press + payment success (NWC and WebLN)
- FollowButton and ProfilePage follow toggle
- ComposeBox (post, voice message, and poll publish success)
- NoteMoreMenu (bookmark, pin, mute)
- VinesFeedPage reaction and repost buttons
- ProfileReactionButton and ExternalReactionButton
- NoteCard share button
- BlobbiRoomShell swipe navigation

Replaces raw navigator.vibrate() calls in GameControls and
SendAnimation with the new cross-platform haptics utility, fixing
haptic feedback on iOS where the Vibration API is not available.
2026-04-14 11:06:18 -05:00
Alex Gleason 328c858e4e Merge branch 'fix/emoji-autocomplete-click' into 'main'
Fix emoji/mention autocomplete dropdowns not clickable in compose modal

Closes #221

See merge request soapbox-pub/ditto!184
2026-04-14 02:25:11 +00:00
Mary Kate Fain dcf77aac2a Add pointer-events-auto to autocomplete dropdowns
Radix Dialog's DismissableLayer sets pointer-events: none on
document.body when the modal is open. Since the dropdowns are portaled
to document.body, they inherit this and silently swallow all mouse
events. Adding pointer-events-auto restores click delivery.
2026-04-13 21:14:52 -05:00
Mary Kate Fain cdf3391aad Fix emoji/mention autocomplete dropdowns not clickable in compose modal
The autocomplete dropdowns are portaled to document.body to escape
overflow clipping, which places them outside the Radix Dialog DOM tree.
Clicks on them were treated as 'interact outside' the dialog, preventing
mouse selection of emoji and mention suggestions.

Add data-autocomplete-dropdown attribute to both dropdown containers and
check for it in handleInteractOutside to prevent modal dismissal.
2026-04-13 21:09:58 -05:00
Mary Kate Fain 787446b4ee Update package-lock.json: remove stale dev flags from esbuild optional deps 2026-04-13 20:56:56 -05:00
Mary Kate Fain 5febdb2d7d Increase widget header icon size to match text-xl label 2026-04-13 20:54:08 -05:00
Mary Kate Fain 005f40b536 Increase widget header label to text-xl 2026-04-13 20:52:35 -05:00
Mary Kate Fain 01a6012a0a Simplify sidebar widgets: remove collapse, reposition drag handle, clean up borders 2026-04-13 20:48:36 -05:00
Chad Curtis c009eb4d5c Fix inline zap rendering: add EmbeddedZapCard for kind 9735
Zap receipts embedded via nostr:nevent1 references were falling through
to the generic EmbeddedNoteCard, which rendered the raw JSON content of
the zap request. Add a dedicated EmbeddedZapCard that extracts and
displays the sender, amount, and message using the existing zap utility
functions. Forwards disableHoverCards to prevent nested hover cards.
2026-04-13 19:05:18 -05:00
Chad Curtis 9bdfa1a485 Merge branch 'improve/blobbi-stuck-behavior' into 'main'
update: reduce stuck recovery chance threshold from 30% to 10%

See merge request soapbox-pub/ditto!183
2026-04-13 23:48:56 +00:00
Chad Curtis 6742792e90 Fix embedded quote review issues
- Add disableMediaEmbeds prop to NoteContent that suppresses images,
  galleries, and video/audio inside embedded quotes while preserving
  link preview cards and lightning invoices
- Render inline fallback links for nevent/naddr references when
  disableNoteEmbeds is true, instead of returning null and leaving
  invisible gaps in quoted text
- Restore tag-based title/description fallback for events with empty
  content (articles, custom addressable kinds) so they don't render
  blank cards
- Migrate EmbeddedProfileBadgesCard to useProfileUrl for consistent
  profile link routing
2026-04-13 18:15:31 -05:00
Chad Curtis 8f6d52a9f9 Fix embedded quote rendering: use NoteContent for DRY media/blobbi display
- Fix stateful global regex bug (IMETA_MEDIA_URL_REGEX) causing every
  other URL to be misclassified when used with .test() in loops; add
  non-global IMETA_MEDIA_URL_TEST_REGEX for safe .test() calls
- Rewrite EmbeddedNoteCard to render content via NoteContent (same as
  NoteCard) with a 260px height cap instead of reimplementing URL
  parsing and content truncation
- Pass disableEmbeds to NoteContent inside quotes to prevent recursive
  nostr:nevent/note references from spawning nested EmbeddedNote
  components
- Add overflow-aware 'Read more' toggle inline with attachment chips;
  fade gradient only renders when content actually overflows
- Add BlobbiStateCard rendering for kind 31124 in both EmbeddedNote
  and EmbeddedNaddr
- Extract EmbeddedCardShell with shared clickable card wrapper and
  author row, deduplicating ~150 lines across EmbeddedNoteCard,
  EmbeddedNaddrCard, and EmbeddedBlobbiCard
- Fix ComposeBox media URL detection using the same regex fix
- Fix EmbeddedNaddr profile links to use useProfileUrl instead of
  hardcoded npub paths
2026-04-13 18:03:09 -05:00
Chad Curtis 51a25919c7 Fix media rendering after quote posts in q-tag quotes
Video/audio/webxdc URLs were silently stripped from NoteContent's token
stream and rendered by parent components after NoteContent. When a quote
post's nostr: URI appeared at the end of the content, media was placed
after the quote embed instead of before it.

Render all media inline within NoteContent at their original content
position via a new media-embed token type. Remove the now-unused
NoteMedia component and the separate media rendering in NoteCard,
PostDetailPage, and ComposeBox.

Also:
- Append media-embed tokens for imeta-declared media not in content
  (gated to text note kinds 1/11/1111 only)
- Sanitize imeta-sourced URLs via sanitizeUrl()
- Skip useAuthor query when no media-embed tokens exist
- Memoize author display name derivation
2026-04-13 17:10:16 -05:00
Chad Curtis 1405b5e2c2 Merge branch 'feat/blobbi-rooms-progression' into 'main'
Rewrite progression, missions system in Blobbi

See merge request soapbox-pub/ditto!179
2026-04-13 21:43:16 +00:00
Chad Curtis 8b3b412b16 Persist poop cleanup XP to companion (debounced publish)
Each poop cleaned awards 5 XP to the companion's experience tag.
Multiple pickups are debounced into a single Nostr publish (1.5s
after the last cleanup) to avoid excessive relay traffic. Uses
ensureCanonicalBeforeAction for fresh-read safety.
2026-04-13 16:34:40 -05:00
Chad Curtis bbcefbb79e Fix review findings: wire up daily XP award, stale-read safety, poop toast, carousel index reset
- Wire useAwardDailyXp into BlobbiDashboard so daily mission XP is
  actually persisted (was exported but never called)
- Rewrite useAwardDailyXp to use fetchFreshEvent + prev pattern
  instead of reading from stale TanStack Query cache
- Remove misleading '+XP' toast from poop cleanup; delegate to parent
  via onPoopCleaned callback with honest 'Cleaned up!' message
- Fix room-config.ts comment to accurately describe room tag status
  (read on mount, not yet written back on room change)
- Make handleOpenShopFromAction navigate to kitchen room instead of
  silently closing the modal
- Reset ItemCarousel index to 0 when items array changes to prevent
  out-of-bounds access
- Derive KitchenBar foodEntries from foodItems memo instead of
  duplicating getLiveShopItems().filter(food)
- CareBar Treat button: memoize treat item, show its name as label,
  handle missing item gracefully
- Fix useItemCooldown: remove module-level side-effect subscription,
  use proper useSyncExternalStore subscribe contract
2026-04-13 16:29:51 -05:00
Chad Curtis 83f2f1de7e Merge origin/main into feat/blobbi-rooms-progression, fix lint warnings 2026-04-13 16:12:35 -05:00
filemon 3dd77c2fcc update: reduce stuck recovery chance threshold from 30% to 10% 2026-04-13 17:45:49 -03:00
Alex Gleason b51b11063f Merge branch 'new-sidebar' into 'main'
Add customizable widget sidebar for the main feed right column

Closes #175

See merge request soapbox-pub/ditto!181
2026-04-13 20:19:17 +00:00
Mary Kate Fain 4ffa3119a7 Fix self-review findings: CSS injection, stale reads, type safety, error states
- Sanitize AI tool background_url with sanitizeUrl() to prevent CSS injection
- Replace 'as unknown as' and 'as Partial<Record>' type escapes with proper
  ChatCompletionTool, ChatCompletionResponseMessage, and ChatCompletionToolCall
  types in useShakespeare
- BlueskyWidget: throw on !res.ok so useQuery retry works; type response
- WikipediaWidget: add explicit isError state instead of masking as 'no article'
- Pass prev (profileEvent) to publishEvent on KIND_BLOBBONAUT_PROFILE mutations
  in BlobbiWidget and BlobbiPage to preserve published_at
- Add profileEvent field to EnsureCanonicalResult interface
- useEncryptedSettings: fetch fresh event from relays before mutation instead
  of reading from stale TanStack cache (cross-device safety)
2026-04-12 18:29:36 -05:00
Mary Kate Fain dbf7ed9bb2 Fix unsanitized Nostr URLs, stale-read mutations, and missing prev on addressable events
- Sanitize imeta URLs at the parse layer in PhotoWidget (parseFirstPhoto)
- Sanitize all URLs from Nostr event tags in musicHelpers (parseMusicTrack,
  parseMusicPlaylist): audio URL, artwork, video, playlist artwork
- Fix stale-read-then-write in handleSetAsCompanion (BlobbiWidget + BlobbiPage):
  use ensureCanonicalBeforeAction to fetch fresh profile from relays instead of
  reading profile.allTags from TanStack Query cache
- Pass prev to publishEvent for KIND_BLOBBI_STATE (addressable kind 31124) in
  both BlobbiWidget and BlobbiPage handleRest to preserve published_at
- Fix usePublishStatus: fetch previous kind 30315 event before publishing to
  preserve published_at per addressable event convention
2026-04-12 17:57:28 -05:00
Mary Kate Fain 8f5f33560e Fix AI chat widget: sticky input at bottom, remove redundant Full chat link
For fillHeight widgets, WidgetCard now renders content in a plain
fixed-height div instead of a ScrollArea, so the widget's internal
flex layout can properly fill the container with messages scrolling
above and input pinned at the bottom.

Remove the 'Full chat' link since the widget header already links
to /ai-chat.
2026-04-12 17:18:09 -05:00
Mary Kate Fain 41392d9299 Extract BlobbiAwayState into shared component
Move the 'out exploring' UI into src/blobbi/ui/BlobbiAwayState.tsx with
size presets ('md' for page, 'sm' for widget). Both BlobbiPage and
BlobbiWidget now import from the shared component instead of rendering
the away state inline.
2026-04-12 17:07:36 -05:00
Mary Kate Fain 4623438652 Extract DorkThinking into shared component, use in AI chat widget
Move the animated Dork face (the <[o_o]> thinking animation) into
src/components/DorkThinking.tsx with a className prop for sizing.
Both AIChatPage and AIChatWidget now import from the shared component.
The widget uses text-[10px] for a compact fit inside the chat bubble.
2026-04-12 16:50:53 -05:00
Mary Kate Fain 6948938768 Fix AI chat widget layout with fillHeight option for fixed-height widgets
Add fillHeight property to WidgetDefinition. When true, WidgetCard uses
a fixed height instead of max-height on the ScrollArea, allowing the
widget's internal flex layout to properly fill the container. The AI chat
widget's messages area now scrolls correctly at a fixed height instead
of awkwardly growing with content.
2026-04-12 16:46:17 -05:00
Mary Kate Fain db9cdd04c5 Fix AI chat widget 400 error by fetching available models dynamically
The widget was hardcoding 'shakespeare' as the model name, which is
not a valid model ID. Now fetches available models from the API and
uses the cheapest one as default, matching how AIChatPage works.
2026-04-12 16:39:43 -05:00
Mary Kate Fain 528cf905fb Show 'out exploring' state in Blobbi widget when companion is floating
Uses useBlobbiCompanionData() to detect if this Blobbi is the active
floating companion (same check as BlobbiPage). When active, hides the
visual and stat wheels, showing a Footprints icon + 'Out exploring
with you' message + gradient 'Bring home' button instead.
2026-04-12 16:29:09 -05:00
Mary Kate Fain 2c08bcd94a Add Take Along companion toggle button to Blobbi widget 2026-04-12 16:21:14 -05:00
Mary Kate Fain 9de3fa7112 Unify stat rings and action buttons into clickable StatIndicator wheels
Extract StatIndicator into a shared component (src/blobbi/ui/StatIndicator.tsx)
with size ('sm'/'md') and onClick/disabled props. Reuse it in both
BlobbiPage (display-only, size='md') and BlobbiWidget (clickable, size='sm').

The widget now shows a single row of stat wheels that double as action
buttons: clicking the hunger wheel feeds, hygiene cleans, health heals,
happiness plays, and energy toggles sleep/wake. Removes the separate
action button row entirely.
2026-04-12 16:11:53 -05:00
Mary Kate Fain 28027cd7b2 Add Heal (medicine) quick action button to Blobbi widget 2026-04-12 16:00:48 -05:00
Mary Kate Fain e54fad61ae Replace stat bars with compact circular ring indicators in Blobbi widget
Uses the same SVG progress ring + lucide icon pattern as BlobbiPage,
scaled down to 36px circles. Shows warning/critical alert triangles
on low stats. Much more compact vertically than the horizontal bars.
2026-04-12 15:58:00 -05:00
Mary Kate Fain 31189801f8 Fix Blobbi widget: show status-reactive visuals and increase height for action buttons
- Pass useStatusReaction recipe to BlobbiStageVisual so the widget
  reflects the actual health state (dizzy eyes, stink clouds, etc.)
- Increase default widget height from 280px to 350px so quick action
  buttons aren't clipped by the scroll container
2026-04-12 15:53:50 -05:00
Mary Kate Fain d579e91bbd Enhance Blobbi widget with live stats and quick action buttons
- Syncs companion selection with BlobbiPage (localStorage + profile.has)
- Shows projected decay stats that update every 60s
- Adds Feed, Play, Clean, and Sleep/Wake quick action buttons
- Hides actions irrelevant to the current stage (eggs can't eat/play/sleep)
- Uses the same ensureCanonical + decay + publish flow as BlobbiPage
- Buttons disable while an action is in progress
2026-04-12 15:41:28 -05:00
Mary Kate Fain 27133d69f2 Use curator follow list for Articles and Events widgets when logged out 2026-04-12 15:29:40 -05:00
Mary Kate Fain 5e895e59ae Replace Photos and Music widgets with rich single-item formats
Photos widget shows the latest photo with image, author, and caption.
Music widget shows the latest track with artwork and playable controls
via the global audio player. Both scope to the user's follow list when
logged in, or the curator's follow list when logged out.
2026-04-12 15:23:34 -05:00
Mary Kate Fain c5f9f8be6c Remove Books sidebar widget 2026-04-12 15:14:51 -05:00
Mary Kate Fain 1a58875418 Make widget header labels clickable links to their full pages 2026-04-12 15:09:40 -05:00
Mary Kate Fain 8ee6388ab8 Fix extra empty space in widgets by using max-height instead of fixed height 2026-04-12 15:02:43 -05:00
Mary Kate Fain 5878b8ad5f Set default sidebar widgets to Trending, Hot Posts, and Wikipedia 2026-04-12 14:59:43 -05:00
Mary Kate Fain ec4359f1aa Add Hot Posts sidebar widget showing top posts from the hot feed 2026-04-12 14:57:19 -05:00
Mary Kate Fain f217394012 Merge remote-tracking branch 'origin/main' into new-sidebar 2026-04-12 14:47:09 -05:00
Alex Gleason 32908f7b4f release: v2.6.6 2026-04-12 14:32:14 -05:00
Alex Gleason bd333b9584 Fix Android WebView resize bugs caused by @capacitor/keyboard
Remove resizeOnFullScreen config which caused possiblyResizeChildOfContent()
to corrupt CoordinatorLayout height on Android 16 (API 36). Upgrade plugin
from 8.0.2 to 8.0.3 which adds a SystemBars guard as additional safety.
Platform-gate setAccessoryBarVisible to iOS only (unimplemented on Android).
2026-04-12 14:07:52 -05:00
Alex Gleason 3ac1dc6b0a Fix dialog obscured by virtual keyboard on Android Chrome
Add interactive-widget=resizes-content to the viewport meta tag so
Chrome on Android resizes the layout viewport when the on-screen
keyboard opens. This keeps fixed-position dialogs (compose, reply,
login, etc.) centered in the visible area above the keyboard.
2026-04-12 13:21:21 -05:00
Alex Gleason 025ecd8645 Upgrade nostrify: improve NIP-46 signing reliability 2026-04-12 12:02:45 -05:00
Alex Gleason 0fca39a1bd Remove androidResume utility and its foreground-resume retry logic
The visibility-change-based Android resume detection was causing more
problems than it solved. Remove the module and simplify LoginDialog and
signerWithNudge to operate without retry-on-resume behavior.
2026-04-12 11:37:10 -05:00
Chad Curtis 3152f7f0ec Merge branch 'fix/emoji-shortcode-autocomplete' into 'main'
Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text

Closes #216

See merge request soapbox-pub/ditto!160
2026-04-12 14:13:32 +00:00
Alex Gleason 7cba044b9d release: v2.6.5 2026-04-11 18:15:04 -05:00
Alex Gleason 4245b2aede Add Google Play publishing to CI release pipeline 2026-04-11 18:10:29 -05:00
Alex Gleason 3cdec3ceb6 Add more Zapstore publish relays to CI 2026-04-11 17:57:13 -05:00
Alex Gleason aa8f7539ae Fix iOS App Store blockers: bundle PrivacyInfo.xcprivacy and declare export compliance 2026-04-11 17:55:26 -05:00
Alex Gleason c6b3cb8758 Remove server.hostname to fix external API requests on Android
The WebView was intercepting all https://ditto.pub/* requests as local
assets, causing favicon and link-preview API calls to fail. Deep links
are unaffected as they use AndroidManifest intent-filters.
2026-04-11 17:37:57 -05:00
Alex Gleason 59f68efdc7 iOS: replace HTML spinner with native UIActivityIndicatorView overlay
The HTML spinner loaded via loadHTMLString was immediately replaced by
the real navigation and never had a chance to render. This is the same
problem Android had with its HTML spinner (though for a different
reason — Android's froze due to main thread saturation).

Use a native UIActivityIndicatorView on a dark overlay, matching the
Android approach with ProgressBar. The spinner is added as a subview
on top of the WKWebView inside a container UIView, and removed in
webView(_:didFinish:) via WKNavigationDelegate.

Also wraps the WKWebView in a container UIView (like Android's
FrameLayout) so the spinner overlay can sit on top independently.
2026-04-11 17:25:06 -05:00
Alex Gleason dc81585f9a Pre-fetch all nsite blobs on Android before WebView navigates
Android's shouldInterceptRequest blocks a pool of ~6 IO threads, each
waiting for JS to respond via the Capacitor bridge. With 200+ files
each requiring a network round-trip to Blossom, loading is painfully
slow. iOS doesn't have this problem — WKURLSchemeHandler is async.

Split the native plugin lifecycle into create() and navigate():
- create() adds the WebView container with spinner overlay (visible)
- navigate() loads the entry URL (triggers fetch interception)

On Android, onReady downloads all manifest blobs in parallel (12
concurrent fetches) into an in-memory cache while the native
ProgressBar spinner animates. Once navigate() fires, every resolveFile
call is an instant cache hit.

On iOS/web, onReady is a no-op and navigate() fires immediately.
2026-04-11 17:20:21 -05:00
Alex Gleason 54e6c964db Add Blossom server affinity to speed up nsite loading
The fetchFromBlossom function previously tried servers sequentially for
every file request. For nsites without server tags (falling back to 3
app default servers), each of the 200+ files paid a full round-trip
penalty when the first server returned 404 before falling through.

Now tracks a module-level preferred server. Once any server successfully
serves a blob it becomes preferred and is tried first for all subsequent
requests. This means only the first file pays the discovery cost; the
rest go directly to the server that has the content.
2026-04-11 17:20:06 -05:00
Alex Gleason dceda199c3 Add loading spinners to native sandbox WebViews
iOS: load inline spinner HTML (centered spinning ring on dark background)
before navigating to the real content URL. Supports light/dark mode via
prefers-color-scheme. The spinner is replaced when the real page loads.

Android: use a native ProgressBar overlay instead of HTML — the HTML
spinner froze because constant Capacitor bridge calls saturated the
main thread, starving the WebView compositor. The native ProgressBar
animates on the render thread independently. Wrapped in a FrameLayout
with a dark overlay behind the spinner.

Both platforms: set WebView background to #14161f (app dark theme)
instead of white. Increased Android shouldInterceptRequest timeout
from 10s to 60s to prevent premature timeouts on large nsites.
2026-04-11 17:20:01 -05:00
Alex Gleason 8967012035 release: v2.6.4 2026-04-11 15:43:47 -05:00
Alex Gleason 0b73d4aac5 Remove dedicated Share button from profile pages
The 'Copy profile link' option is already available in the more menu,
making the standalone Share button redundant.
2026-04-11 15:40:08 -05:00
Alex Gleason 6f53f7ad99 Fix avatar fallback showing '?' instead of name initial
ComposeBox and LeftSidebar avatar fallbacks only checked metadata.name,
ignoring display_name and genUserName. Now uses the same fallback chain
as ProfileCard: display_name -> name -> genUserName(pubkey). Also fixed
the getDisplayName helper in LeftSidebar to check display_name.
2026-04-11 15:36:47 -05:00
Alex Gleason 399df4da4d Improve empty feed state with icon and discover CTA
Redesign FeedEmptyState with a centered icon, cleaner layout, and
two actionable buttons for the follows tab: 'Discover people to
follow' linking to /packs, and 'Browse the Global feed' to switch
tabs. Other call sites are unaffected (new props are optional).
2026-04-11 15:29:10 -05:00
Alex Gleason c06a66ade4 Ensure sticky desktop FAB anchors to bottom on empty feeds
Add min-h-dvh to the Feed <main> element so it always fills at least
the viewport height. Without this, the sticky FAB (a sibling after
<main>) sits in normal flow right after the short content instead of
at the bottom of the center column.
2026-04-11 15:25:37 -05:00
Alex Gleason 1fca26ae2e Clean up signup profile step: hide pencil badges, remove extra fields
- Hide the small pencil icon on avatar and banner until an image is
  actually set (the hover overlay still shows so users can discover
  the action)
- Remove the Profile Fields collapsible from the signup flow to keep
  the onboarding lightweight
2026-04-11 15:12:28 -05:00
Alex Gleason ccd8f213f6 Replace Skip/Continue with single Continue button in profile step
handlePublishProfile already skips publishing when no data is entered,
so the Skip button was redundant. A single full-width Continue button
simplifies the UI.
2026-04-11 15:09:38 -05:00
Alex Gleason 1c25702453 Fix signup dialog not clearing background when switching to light/dark theme
ThemeStep was reading customTheme?.background?.url unconditionally,
so the background persisted even after selecting a built-in theme.
Now resolves the active theme config the same way AppProvider does,
only showing the background when the active theme actually has one.
2026-04-11 14:58:52 -05:00
Alex Gleason 357ba7d8c8 fix: migrate to SystemBars API for Android 16+ safe area inset support
Android 16 (API 36) enforces edge-to-edge rendering unconditionally,
breaking @capacitor/status-bar's setOverlaysWebView and setBackgroundColor.
Additionally, a Chromium bug (<140) causes env(safe-area-inset-*) to report
0 in some Android WebViews.

- Replace @capacitor/status-bar with SystemBars from @capacitor/core 8+
- Enable insetsHandling: 'css' in capacitor.config.ts so the SystemBars
  plugin injects --safe-area-inset-* CSS variables on Android
- Update all safe area CSS utilities and inline styles to use
  var(--safe-area-inset-*, env(safe-area-inset-*, 0px)) fallback pattern
- Remove @capacitor/status-bar dependency (no longer needed)
2026-04-11 14:47:15 -05:00
Alex Gleason 207ca6893a Add iCloud Keychain credential saving/restoring on iOS via @capgo/capacitor-autofill-save-password
- Use SecAddSharedWebCredential to prompt 'Save Password?' on signup
- Use ASAuthorizationPasswordProvider to restore credentials on login
- Add webcredentials:ditto.pub Associated Domains entitlement
- Deploy apple-app-site-association for domain validation
- Keep existing Chromium PasswordCredential flow as web fallback
- Add saveNsec() helper: native credential manager on iOS/Android,
  file download + bonus PasswordCredential on web
- Single 'Continue' button triggers the appropriate save method per platform
2026-04-11 14:01:34 -05:00
Chad Curtis 6dc7fb7ade Replace localStorage with in-memory Map for daily missions
Daily mission session state is now a pubkey-scoped Map instead of
localStorage. Hydrates from kind 11125 content on mount/account switch.
Completed missions are already persisted by useAwardDailyXp; intermediate
progress resets on refresh (low-impact).
2026-04-11 10:28:33 -05:00
Alex Gleason 37df5d0bd1 release: v2.6.3 2026-04-10 23:27:38 -05:00
Alex Gleason 19906cf918 Merge branch 'fix/badge-image-aspect-ratio-hint' into 'main'
Show recommended 1:1 aspect ratio hint on badge image upload

Closes #212

See merge request soapbox-pub/ditto!178
2026-04-11 03:49:14 +00:00
Alex Gleason 874010c4fe Store nsec in browser password manager via Credential Management API
Progressive enhancement using PasswordCredential (Chromium-only).
On sign-up, the nsec is offered to the browser's password manager
alongside the existing file download. The prompt appears while the
user is looking at their key on the download step. On login, stored
credentials are retrieved for one-tap login on supported browsers.

Safari/Firefox/iOS silently skip — existing flows are unchanged.
2026-04-10 21:49:14 -05:00
Chad Curtis d256acdef3 package-lock.json to main 2026-04-10 17:23:07 -05:00
Chad Curtis 98e0273bdb Fix sleeping blobbi not showing bedroom room 2026-04-10 17:21:51 -05:00
Mary Kate Fain e26407d740 Change default sidebar widgets to Trends, Bluesky, and Wikipedia 2026-04-10 17:18:08 -05:00
Chad Curtis b42f12ce77 Fridge as blur overlay, room UX refinements
- Fridge opens as full-page blur overlay with flex-wrap food grid
  and 2x2 stat icons per item (lucide icons, no boxes/borders)
- X dismiss button with strokeWidth 4, click negative space to close
- Overlay renders above navigation arrows (z-50)
- Sleeping Blobbi cannot leave bedroom (toast + gate on room change)
- Upgrade lucide-react, add arrow nudge keyframe animations
- Replace all emoji button/room icons with lucide equivalents
- Room indicator moved below Blobbi name in hero
- Touch swipe support on room shell
- Larger nav arrows (size-7/8, strokeWidth 4)
2026-04-10 17:16:17 -05:00
Chad Curtis 7a10e4a406 Room UX polish: swipe navigation, lucide icons, indicator below name
- Add touch swipe support to BlobbiRoomShell (50px threshold)
- Larger navigation arrows (size-7/8) with strokeWidth 4
- Move room indicator (icon + label + dots) below Blobbi name in hero
- Remove room header overlay from shell top
- Replace all emoji button icons with lucide: Refrigerator, ShowerHead,
  TowelRack, Candy, Shovel
- Replace room config emoji icons with lucide: Home, Refrigerator, Cross,
  Moon, Shirt
- Upgrade lucide-react 0.462.0 -> 1.8.0
- Add room-arrow-nudge keyframe animations to index.css
2026-04-10 17:03:52 -05:00
Chad Curtis eda18d8b93 Integrate room system into BlobbiPage
- Replace Care + Items tabs with room-based navigation
- Drawer shrinks to Quests + Blobbis only
- Room shell renders hero + nav arrows + dots + sleep overlay + poop
- Per-room bottom bars: HomeBar (toys/music/sing + photo + companion),
  KitchenBar (food carousel + shovel + fridge), CareBar (hygiene/medicine
  with context-sensitive side buttons), RestBar (sleep/wake), ClosetBar
- Remove CareTabContent, CareActionButton, ItemsTabContent, ItemTypeIndicator,
  StatIndicator and related constants (now in BlobbiRoomHero)
- Net reduction: -72 lines from BlobbiPage
2026-04-10 16:49:29 -05:00
Alex Gleason 126dce1dfc Surface account deletion as 'Delete Account' for App Store compliance
Add a 'Delete Account' pill button to the bottom of the Settings
page (Guideline 5.1.1v). Rename the Danger Zone heading in Advanced
Settings to match. Simplify the deletion dialog to a single screen:
plain-language warning, list of what gets deleted, type DELETE to
confirm, and Cancel/Delete buttons. Always broadcasts to all relays.

The underlying NIP-62 mechanism and components that render vanish
events to other users are unchanged.
2026-04-10 16:44:35 -05:00
Chad Curtis 70809a8c7c Add BlobbiRoomHero and BlobbiRoomShell components
- BlobbiRoomHero: focused props interface (15 props, not 80+), stats crown
  with arc layout, responsive sizing, sleep animation suppression,
  floating companion placeholder
- BlobbiRoomShell: children-based layout, room navigation arrows with
  destination labels, pagination dots, sleep overlay, ephemeral poop
  state management, hero/middle/children slot architecture
2026-04-10 16:24:17 -05:00
Chad Curtis 5b15300f23 Add room system foundation: config, layout, poop system, carousel, action button
- room-config.ts: room IDs, metadata, navigation helpers, default order (no hatchery)
- room-layout.ts: shared bottom bar class constant
- poop-system.ts: ephemeral poop generation/cleanup with XP reward
- ItemCarousel.tsx: single-focus item carousel with prev/next previews
- RoomActionButton.tsx: unified circular action button for room bottom bars
- Add 'room' tag to kind 11125 schema for cross-session persistence
- Barrel exports from rooms/index.ts
2026-04-10 16:17:04 -05:00
Alex Gleason 105da53e2e Add NSCameraUsageDescription to Info.plist
File inputs with accept="image/*" present a camera option on iOS.
Without this usage description, WKWebView crashes or fails to show
the permission dialog when the user selects 'Take Photo'.
2026-04-10 16:10:35 -05:00
Chad Curtis 8585dd4833 Add item cooldown module and poop cleanup XP constant
- item-cooldown.ts: shared singleton with per-item cooldown tracking,
  subscriber system for React integration
- useItemCooldown.ts: useSyncExternalStore hook for reactive cooldown state
- Add POOP_CLEANUP_XP (5) to blobbi-xp.ts
- Export all new APIs from actions barrel
2026-04-10 16:05:02 -05:00
Alex Gleason 7bc4a632b0 Add XCode DEVELOPMENT_TEAM to project.pbxproj 2026-04-10 16:03:38 -05:00
Chad Curtis 12bda76526 Rewrite progression and missions system: tags-first, minimal, DRY
- Add progression.ts: xpToLevel, levelToXp, xpProgress, getUnlocks (pure functions, ~110 lines)
- Add missions.ts: tally/event mission types, ProfileContent parse/serialize for kind 11125 content
- Add xp/level tags to kind 11125 BlobbonautProfile schema
- Rewrite daily-missions.ts: drop completed/claimed/currentCount, use target+count/events model
- Unify hatch/evolve into single 'evolution' key in missions content
- Replace coin rewards with XP rewards throughout
- Remove explicit claim flow (completion is implicit from progress >= target)
- Rewrite tracker, hooks, and UI consumers to new data shape
- Guard against old localStorage format during migration
2026-04-10 15:53:36 -05:00
Alex Gleason 0222248d76 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-10 15:49:15 -05:00
Alex Gleason a542dd3b36 Sanitize all event-sourced URLs and prevent CSS injection
Nostr events are untrusted user input. Any URL extracted from event tags
or metadata must be validated before use in any context — not just
navigable hrefs, but also img src, CSS url(), and style attributes.

Changes:
- Theme events (kind 16767/36767): validate background and font URLs
  through sanitizeUrl() at parse time in themeEvent.ts
- Badge definitions (kind 30009): validate image and thumb URLs through
  sanitizeUrl() at parse time in parseBadgeDefinition.ts
- Font family names: sanitize with an allowlist regex before
  interpolation into CSS declarations in fontLoader.ts
- Profile fields: replace weak startsWith('http://') checks with
  sanitizeUrl() in ProfileRightSidebar and ProfilePage
- Community descriptions: validate extracted URLs through sanitizeUrl()
  in CommunityContent.tsx
- AGENTS.md: mandate unconditional URL sanitization for all
  event-sourced URLs regardless of rendering context, document CSS
  injection prevention guidelines
2026-04-10 15:48:38 -05:00
Mary Kate Fain fc292a8654 Replace screenshots table with simpler Before/After format in MR template 2026-04-10 15:15:54 -05:00
Mary Kate Fain 9214bd823b Remove redundant Submission checklist from MR template 2026-04-10 15:14:54 -05:00
Mary Kate Fain 8f5b8264c9 Show recommended 1:1 aspect ratio hint on badge image upload 2026-04-10 15:13:18 -05:00
Alex Gleason 94f821d064 Merge branch 'contributor-quality-gates' into 'main'
Add contributor quality gates: CONTRIBUTING.md, MR template, and CI validation

See merge request soapbox-pub/ditto!177
2026-04-10 19:50:29 +00:00
Mary Kate 6d73e6d06b Add contributor quality gates: CONTRIBUTING.md, MR template, and CI validation 2026-04-10 19:50:28 +00:00
Alex Gleason bd724de1e8 Bump @unhead/addons and @unhead/react to ^2.1.13 to fix CVE-2026-39315
The vulnerability (GHSA-95h2-gj7x-gx9w) allows bypassing hasDangerousProtocol()
in useHeadSafe() via leading-zero padded HTML entities. Not currently reachable
in this codebase (we only use useSeoMeta), but closes the CVE in the dependency
tree.
2026-04-10 14:29:49 -05:00
Alex Gleason 9d899cfe87 Sanitize all user-supplied URLs from Nostr events to prevent javascript: XSS
Add a shared sanitizeUrl() utility that validates URLs are well-formed
https: before they reach href attributes, window.open(), or openUrl().

Apply sanitization across all components that render untrusted URLs:
- CalendarEventDetailPage: r-tag links
- ZapstoreAppContent: url and repository tags
- ZapstoreReleaseContent: asset url tags passed to openUrl()
- AppHandlerContent: web handler tags and metadata.website
- NsiteCard: source tag
- GitRepoCard: web tag URLs passed to openUrl()
- FileMetadataContent: url tag used in download href
- ProfilePage: metadata.website (tighten weak startsWith check)
- useUserStatus: r-tag URL

Document sanitizeUrl usage in AGENTS.md for future agent use.
2026-04-10 14:22:42 -05:00
Mary Kate Fain 173f789242 Extract shared portal dropdown logic into usePortalDropdown hook
Both EmojiShortcodeAutocomplete and MentionAutocomplete had identical
logic for fixed viewport positioning with viewport-flip, scroll/resize
dismissal, and portal rendering. Extract into a shared hook to reduce
duplication and centralize the positioning behavior.
2026-04-10 12:50:05 -05:00
Mary Kate Fain 07a9b956cb Remove dead WidgetContext: hook was never consumed by any component 2026-04-10 11:18:13 -05:00
Mary Kate Fain 0e7f847de0 Medium-priority fixes: decouple sparkline, Map lookup, memo widgets, fix drag closure
8. Extract TrendSparkline to its own file so TrendingWidget doesn't
   depend on the old RightSidebar (re-export kept for compat)
9. Widget definition lookup uses a pre-built Map instead of linear scan
10. SortableWidget wrapped in React.memo to skip re-renders when only
    sibling state changes (picker open, other widget collapse)
11. handleDragEnd computes indices from the updater's current array
    instead of closing over sortableIds (eliminates stale closure risk
    if a query refetch re-renders mid-drag)
2026-04-10 10:33:27 -05:00
Mary Kate Fain 4998ea8f5d Fix high-priority widget issues: scoped queries, Bluesky isolation, Capacitor compat
5. FeedWidget now scopes queries to followed authors when logged in,
   falls back to global when logged out, and requests exact limit
6. BlueskyWidget uses its own useQuery instead of sharing the infinite
   query with BlueskyPage (separate query key, single page, no memory leak)
7. WikipediaWidget uses openUrl() instead of <a target=_blank> which
   silently fails inside Capacitor WKWebView on iOS
2026-04-10 10:27:31 -05:00
Mary Kate Fain 0cc81cd35f Fix 4 blocker issues: error boundaries, resize perf, chat persistence, error handling
1. Wrap each widget in ErrorBoundary so one crash doesn't kill the sidebar
2. Resize uses local state during drag, commits to config only on pointerup
   (was hammering localStorage at 60fps)
3. AI chat messages persist in module-level cache across collapse/expand
   (collapsing previously destroyed the conversation)
4. StatusWidget catches rejected promises from mutateAsync and shows
   destructive toast instead of silently failing
2026-04-10 10:21:50 -05:00
Mary Kate Fain ed09c8947d Fix preset widgets disappearing on first add by falling back to merged config 2026-04-10 09:34:00 -05:00
Mary Kate Fain 2e79d93806 Match 'Add widget' button background to widget card style for visibility 2026-04-10 09:32:05 -05:00
Mary Kate Fain f05097087b Add customizable widget sidebar for the main feed right column
Replace the empty right sidebar placeholder with a user-configurable widget
system. Users can add, remove, reorder, collapse, and resize widgets via
drag-and-drop and a picker dialog. Config persists in localStorage (same
pattern as sidebarOrder) and syncs via encrypted settings.

v1 widgets: Trending Tags, Blobbi (mini pet), Status (NIP-38), AI Chat,
Wikipedia (featured article), Bluesky (trending posts), and feed widgets
for Photos, Music, Articles, Events, and Books.

Defaults: Trending + Blobbi for fresh installs. Desktop-only (hidden below
xl breakpoint). Profile pages retain their dedicated ProfileRightSidebar.
2026-04-10 09:28:04 -05:00
Chad Curtis 72268dfde6 Merge branch 'feat/feed-blobbi-status-visuals' into 'main'
Reflect companion condition in feed Blobbi cards

See merge request soapbox-pub/ditto!169
2026-04-10 13:05:44 +00:00
Alex Gleason 7b63f6112c Clean up profile header: remove lightning address, NIP-05 check icon, and trailing slash from website URLs 2026-04-09 22:30:38 -05:00
Alex Gleason ce61d8d1a6 Restore right sidebar for profile pages, keep fields mobile-only 2026-04-09 22:00:50 -05:00
filemon c4a10b1303 Merge branch 'main' into feat/feed-blobbi-status-visuals 2026-04-09 15:27:28 -03:00
Chad Curtis 76c6846e91 Render BOLT11 lightning invoices in note content
Detect lnbc/lntb/lnbcrt/lntbs invoices (with optional lightning: prefix)
in note text and render them as interactive cards with a theme-aware QR
code, decoded amount, copy button, and Open in Wallet action.

- Add lightning-invoice token type to NoteContent tokenizer
- Create LightningInvoiceCard with tap-to-expand square QR, cqw-scaled
  amount text, and responsive layout
- Extract shared theme-aware QR color logic into src/lib/qrColors.ts
  (deduplicate from FollowQRDialog)
2026-04-09 08:02:26 -05:00
Alex Gleason ac1e82b52d release: v2.6.2 2026-04-08 23:38:31 -05:00
Alex Gleason 437b8de652 Remove right sidebar content and show profile fields inline 2026-04-08 23:34:03 -05:00
Alex Gleason adadb6ed53 Fix native file downloads: save directly to Documents on iOS/Android 2026-04-08 22:54:46 -05:00
Alex Gleason f7c90a4a23 Remove trending hashtags section from logged-out homepage 2026-04-08 22:28:22 -05:00
Alex Gleason 82632bb76c Store nostr:login in secure storage on native platforms
Use capacitor-secure-storage-plugin to persist login credentials
(nsec keys) in iOS Keychain / Android KeyStore instead of plaintext
localStorage. Web behavior is unchanged. Existing native users are
auto-migrated on first launch: if secure storage is empty but
localStorage has data, it is moved over and the plaintext copy is
removed.

Also ignore ios/ directory in ESLint (Capacitor-generated files).
2026-04-08 22:20:48 -05:00
Alex Gleason 3a70d34e6d npm audit fix 2026-04-08 22:12:03 -05:00
Alex Gleason 221d3f4aff Merge branch 'mobile-search' 2026-04-08 22:11:38 -05:00
Alex Gleason 6a1a462ab0 Upgrade @nostrify/react to ^0.5.0 (async storage support)
Upgrade to the new version that includes the NLoginStorage interface
and storage/fallback props on NostrLoginProvider for pluggable async
storage backends (e.g. Capacitor Secure Storage).

- Add resolve.dedupe for react/react-dom to prevent dual-React issues
- Update NoteContent tests to use async findBy* queries since the
  provider now always awaits storage initialization
2026-04-08 22:08:56 -05:00
Alex Gleason 5ee8bc1cc0 Improve mobile search UX: lock scroll, hide bottom nav, dismiss accessory bar, and fix close behavior 2026-04-08 22:04:26 -05:00
Alex Gleason 76d53859cf Simplify webxdc to always open in fullscreen panel 2026-04-08 20:47:46 -05:00
Alex Gleason e482afbd3f Fix sandbox origin isolation and Android build issues 2026-04-08 20:47:42 -05:00
Alex Gleason 11ff27efe2 Enable iOS swipe-back navigation and fix bottom nav layout 2026-04-08 20:47:37 -05:00
Alex Gleason 8f6f678132 Add safe area padding and fix fullscreen sandbox on iOS 2026-04-08 20:47:32 -05:00
Alex Gleason f25139103c Add native SandboxPlugin for iOS and Android 2026-04-08 20:47:28 -05:00
Alex Gleason 0028b506e7 Fix webxdc bridge: serve script via resolveFile instead of injectedScripts
SandboxFrame's virtual script serving intercepted /webxdc.js and served
the empty placeholder content before resolveFile was ever called. The
dynamically generated bridge script (which embeds selfAddr etc.) was
never reaching the iframe.

Move bridge serving and HTML injection into resolveFileWithBridge so
the content is served from bridgeScriptRef after onReady populates it.
2026-04-08 16:55:01 -05:00
Alex Gleason 926c27d51c Fix webxdc race condition: await onReady before sending init
The sandbox frame was sending init immediately and calling onReady
concurrently, so fetch requests arrived before the archive was
downloaded and unzipped. Now onReady is awaited before init is sent,
matching the original Webxdc behavior.
2026-04-08 16:50:44 -05:00
Alex Gleason c4454ee2a1 Refactor iframe.diy usage into unified SandboxFrame component
Extract duplicated sandbox protocol logic from NsitePreviewDialog and
Webxdc into a single SandboxFrame component. Shared utilities (MIME
types, base64, HTML injection, JSON-RPC types) move to src/lib/sandbox/.

Add configurable sandboxDomain to AppConfig so the iframe.diy domain
can be overridden via ditto.json, preparing for native Capacitor
implementations.

Strip unused console/navigation/error RPC from previewInjectedScript,
leaving only the /index.html path normalization.
2026-04-08 16:41:23 -05:00
Chad Curtis e56737f776 Fix blobbi discovery: query by author instead of relying on profile.has[]
The Blobbi collection was previously discovered via the profile's has[] tag
list, meaning any blobbi whose d-tag was missing from that secondary index
would be invisible to the user despite existing on the relay.

Now useBlobbisCollection() without args queries all kind 31124 events by
author + ecosystem namespace tag — the user authored these events, so that
is the source of truth. The profile.has[] list is still used for selection
ordering preference, but no longer gates discovery.

The dList parameter remains available for targeted fetches (e.g. the
companion layer only needs one specific blobbi).
2026-04-08 11:02:03 -05:00
Chad Curtis feb6c1a9f6 Add drop shadow and solid gradient to overflow tab arrows 2026-04-08 10:27:17 -05:00
Chad Curtis 6f8d225597 Increase overflow tab arrow stroke to 4 and boost contrast 2026-04-08 10:22:04 -05:00
Chad Curtis 9ecd99a6a1 Add 'Write a letter' option to profile more menu
Adds a Mail-icon menu item in the profile more menu for other users'
profiles. Navigates to /letters/compose?to={npub} so the recipient is
pre-filled, matching the same flow used by the notification reply button.
2026-04-08 04:01:11 -05:00
Chad Curtis 287097627d Hide delivery method when push disabled; fix persistent description
Only show the delivery method radio group when push notifications are
enabled. Update the persistent option description to explain it is for
devices that don't support push notifications (e.g. GrapheneOS).
2026-04-08 00:20:20 -05:00
Chad Curtis 3ee491a63b Add push vs persistent notification delivery option for Android
Default to push mode (no foreground service). Persistent mode with
the always-on background polling service is opt-in via the new
Delivery Method section in notification settings.

- Add notificationStyle ('push' | 'persistent') to EncryptedSettings
- Show radio group in NotificationSettings on native platforms
- Pass notificationStyle through Capacitor plugin to SharedPreferences
- DittoNotificationPlugin starts/stops foreground service on style change
- MainActivity only starts service on launch when style is persistent
- Re-enable unread polling on native when push mode is active
2026-04-07 10:54:30 -05:00
Chad Curtis 7944f73da3 fix: use fetchFreshEvent and preserve non-p-tags in Follow All handlers
FollowPackDetailContent, TeamSoapboxCard, and InitialSyncGate all had
handleFollowAll implementations that queried kind 3 directly (bypassing
fetchFreshEvent) and rebuilt the tag array with only p-tags, silently
dropping all non-p-tags (relay hints, petnames, etc.). They also did
not pass prev for published_at preservation.

Align all three with the safe pattern already used in FollowPage and
useFollowActions.
2026-04-07 09:03:07 -05:00
Chad Curtis 17c1936817 Support follow pack/set naddr identifiers on /follow URL
The /follow route now accepts naddr1 identifiers for follow packs
(kind 39089) and follow sets (kind 30000) in addition to npub/nprofile.

Renders an immersive fullscreen layout with pack info hero, avatar
stack, big Follow All CTA with status indicator, and Feed/Members
tabs using the standard SubHeaderBar arc.

Follow All uses the safe fetch-fresh -> modify -> publish pattern
with prev for published_at preservation.

Shared components (PackFeedTab, MemberCard, MemberCardSkeleton) and
parsePackEvent are reused from FollowPackDetailContent and packUtils.

Also fixes SubHeaderBar tab indicator positioning when innerClassName
centers the tab container (adds containerOffset + ResizeObserver for
layout-dependent recalculation).
2026-04-07 08:55:27 -05:00
Chad Curtis c570f4689d Merge branch 'curated-ditto-feed' into 'main'
Curate Ditto feed by curator follow list with photos, divines, videos, and music

See merge request soapbox-pub/ditto!164
2026-04-07 12:52:23 +00:00
Chad Curtis 064ab1e101 Address MR review: extract feed hook, fix cache key, add error handling, make curator configurable
- Remove unused 'authors' parameter from useInfiniteHotFeed
- Extract inline query from Feed.tsx into useCuratedDittoFeed hook
- Use content-based fingerprint for query key instead of list length
- Add error state handling so curator fetch failure shows empty state
  instead of infinite skeletons for first-time visitors
- Move hardcoded curator pubkey to AppConfig (curatorPubkey) so it
  can be overridden via ditto.json without a code change
- Remove LANDING_KINDS/LANDING_WEBXDC_FILTER from Feed.tsx (now in hook)
2026-04-07 07:48:23 -05:00
Alex Gleason 9c0d49b904 Add OPFS as blocked API in lockdown-mode skill 2026-04-06 18:42:45 -05:00
Alex Gleason 69634e7c05 Update lockdown-mode skill with cross-platform availability info
Lockdown Mode is not iOS-only — it's available on iOS 16+, iPadOS 16+,
watchOS 10+, and macOS Ventura+. Add platform availability section with
Apple Support reference link, rename report file to ios-report.txt to
clarify it's iOS-specific, and broaden the skill description.
2026-04-06 16:13:57 -05:00
Alex Gleason db48ce7c40 Add raw diagnostic report as skill reference file 2026-04-06 16:05:52 -05:00
Alex Gleason 36c6e537a7 Add lockdown-mode agent skill with iOS Lockdown Mode API reference 2026-04-06 15:59:29 -05:00
Alex Gleason cbc3df0bef Allow any dev server host via ALLOWED_HOSTS env var 2026-04-06 14:40:31 -05:00
Alex Gleason 2ecd557430 Fix IndexedDB crash on iOS Lockdown Mode
openDatabase() now catches errors from idb's openDB() (which throws
synchronously when indexedDB is undefined) and returns null. All
consumers — profileCache, nip05Cache, dmMessageStore — check for null
and silently degrade to in-memory only.

The DM message store also stops re-throwing errors, which previously
could produce unhandled rejections in DMProvider.
2026-04-06 13:41:32 -05:00
filemon 61c84ed137 Fix conditional hook call in BlobbiStateCard
Move the early return for null companion below all hooks so useMemo
calls are unconditional. The null/egg guard is now inside the recipe
useMemo, and isSleeping/isEgg use optional chaining.
2026-04-06 13:46:50 -03:00
filemon a24b755e08 Use projected decay stats for feed Blobbi visuals
Replace raw companion.stats with calculateProjectedDecay() output so
feed cards reflect the Blobbi's real current condition after time-based
stat decay, matching what the room view shows via useProjectedBlobbiState.

The pure calculateProjectedDecay() function is called once per render
inside useMemo (no setInterval per card), keeping feed rendering
lightweight while staying consistent with the room's decay math.
2026-04-06 13:28:42 -03:00
filemon 46a970b900 Reflect companion condition in feed Blobbi cards
BlobbiStateCard now resolves the same status recipe used by the room
view (resolveStatusRecipe) from the on-chain stats, so feed Blobbis
show hunger, dirt, sleepiness, sadness, and sickness visuals.

A new attenuateRecipeForFeed() helper scales down body-effect particle
counts and removes flies to keep the smaller feed-card size readable.
Sleeping Blobbis get the buildSleepingRecipe() overlay, matching the
room behaviour.
2026-04-06 12:42:57 -03:00
Alex Gleason 594e7ea8fa ci: add build-web job to produce downloadable artifact
The old 'pages' job was removed when deploying switched to nsite,
which broke the artifact download URL on the docs site. This adds
a new build-web job that builds the web app on main and saves the
dist/ directory as a downloadable artifact.
2026-04-06 01:09:10 -05:00
Alex Gleason 0a5e72efd0 release: v2.6.1 2026-04-06 00:58:23 -05:00
Alex Gleason 0f1021e0d3 Switch nsite preview from local-shakespeare.dev to iframe.diy
Replace the local-shakespeare.dev preview domain with iframe.diy, which
provides a service-worker based sandbox. This brings the nsite preview
implementation in line with Shakespeare's approach.

Key changes:
- iframe.diy handshake: listen for 'ready', respond with 'init'
- Derive private HMAC-SHA256 subdomains via deriveIframeSubdomain('nsite', ...)
- Inject preview script into HTML responses for console forwarding,
  SPA navigation tracking, and /index.html path normalization
- Remove sandbox attribute (iframe.diy manages its own sandboxing)
- Serve injected script from virtual /__injected__/preview.js path
2026-04-06 00:51:39 -05:00
Alex Gleason be65c659b2 Derive private iframe.diy subdomains with HMAC-SHA256
The i-tag UUID used for webxdc coordination is attacker-controlled.
Using it directly as the iframe.diy subdomain lets a malicious event
author pick a subdomain that collides with another app's origin,
gaining access to its localStorage/IndexedDB.

Introduce a persistent random seed in localStorage (ditto:seed) and
derive the subdomain as base36(HMAC-SHA256(seed, prefix|identifier)).
The prefix (e.g. "webxdc") domain-separates different use-cases.
The subdomain is stable per device+app but unpredictable to event
authors.
2026-04-06 00:33:55 -05:00
Alex Gleason b64aa4b24a Add Content-Security-Policy to webxdc fetch responses
Apply a strict CSP header to every response served from the .xdc archive
to enforce the webxdc offline sandbox. Permits same-origin, inline, eval,
wasm, data: and blob: but blocks all external network access.
2026-04-05 23:20:21 -05:00
Alex Gleason f63d8943d8 Replace webxdc.app with iframe.diy for webxdc sandboxing
Migrate the webxdc iframe runtime from webxdc.app to iframe.diy. Instead of
sending ZIP bytes to the iframe and having the SW unzip them, the parent now
unzips the .xdc archive and serves files via iframe.diy's fetch-proxy RPC.
A webxdc bridge script is served as a virtual /webxdc.js file, and a
<script> tag is injected into HTML responses via DOMParser to load it.

- Rewrite Webxdc.tsx to use iframe.diy's ready/init/fetch protocol
- Unzip .xdc archives on the parent side and serve via fetch RPC responses
- Serve webxdc bridge as virtual /webxdc.js via the fetch handler
- Inject <script src="/webxdc.js"> into HTML using DOMParser
2026-04-05 22:15:16 -05:00
Mary Kate Fain e6efdc3539 Switch Ditto feed from sort:hot to latest-first chronological ordering
Replace useInfiniteHotFeed (sort:hot via NIP-50 search) with standard
NIP-01 reverse-chronological pagination for the curated Ditto feed.
Latest ordering provides a natural time-based spread of content types,
working better with the diversity algorithm and giving a fresher feel.
2026-04-05 20:39:13 -05:00
Alex Gleason 517a72cce7 Fix profile avatar/banner lightbox appearing behind right sidebar
Portal ProfileImageLightbox to document.body, matching the fix
already applied to the shared Lightbox component. Without the
portal, the lightbox was trapped inside the center column's z-0
stacking context from MainLayout, causing the right sidebar
(a sibling outside that context) to paint on top.
2026-04-05 20:37:09 -05:00
Mary Kate Fain ebe0cfdf03 Cap Blobbi at 10% of feed, add per-type cap overrides 2026-04-05 20:30:54 -05:00
Mary Kate Fain a501337fd3 Increase content-type gap from 3 to 4 for better diversity spacing 2026-04-05 20:11:37 -05:00
Mary Kate Fain e3916b3bc1 Fix same-type clustering: drop excess deferred items instead of dumping them
The final drain loop now tries all deferred items (not just the front),
and any items that still can't satisfy the gap constraint are dropped
rather than appended back-to-back. This prevents runs like 3 Blobbis
in a row that occurred when the graceful degradation path blindly
appended all leftover deferred items.
2026-04-05 19:59:32 -05:00
Mary Kate Fain de22e921d4 Fix diversity reordering causing feed jumps on new page load
Process each page independently with gap state carrying forward from
the previous page's tail. Earlier pages never change when new pages
arrive, eliminating the visible re-render/jump. The proportional cap
now applies per-page instead of across the full flattened list.
2026-04-05 19:55:19 -05:00
Mary Kate Fain 3a512f04e2 Add content-type diversity reordering to Ditto feed
Prevent the same content type from appearing within 3 positions of
itself and cap any single type at 20% of the feed. Uses a two-phase
algorithm: proportional cap first (trims excess from least-hot items),
then greedy gap-enforced interleave that keeps items as close to their
original hotness rank as possible. Only applies to the Ditto/landing
feed — follows, global, and other feeds are untouched.
2026-04-05 19:47:04 -05:00
Chad Curtis 6fc68766c9 Merge branch 'refactor/blobbi-remove-item-quantity-selection' into 'main'
Remove quantity selectors from Blobbi item usage flows

See merge request soapbox-pub/ditto!163
2026-04-06 00:38:27 +00:00
Mary Kate Fain bfd1daf7ba Curate Ditto feed by curator follow list with photos, divines, videos, and music
Filter the Ditto tab and logged-out landing feed to only show content
from people followed by the curator npub (npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc),
inclusive of the curator. Add Kind 20 (photos), 21/22 (videos),
34236 (divines), and 36787/34139 (music) to the curated feed kinds.
2026-04-05 19:24:00 -05:00
filemon ae81c13cc1 Merge branch 'main' into fix-items-blobbi-companion 2026-04-05 21:21:31 -03:00
filemon 41358d27ce Update comments and docs to reflect item-as-ability model
Replace outdated references to 'inventory items', 'consume',
'quantity', and 'storage decrement' across 14 files. Comments
now consistently describe items as reusable abilities sourced
from the shop catalog, not consumable inventory.
2026-04-05 21:18:35 -03:00
Chad Curtis ac8bffba23 Merge branch 'fix-items-blobbi-companion' into 'main'
Remove inventory ownership requirement from Blobbi companion items

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

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

Changes:
- Source items from shop catalog instead of user inventory storage
- Remove quantity validation and storage decrement on item use
- Remove quantity badges and 'in inventory' text from all modals
- Keep stage-based filtering (egg vs baby/adult restrictions)
- Cap quantity selector at 99 instead of inventory count
2026-04-05 19:59:30 -03:00
Mary Kate Fain f4363dcbff Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text
- Switch autocomplete dropdowns from absolute to fixed positioning so they
  aren't clipped by ancestor overflow containers (e.g. the compose modal's
  overflow-y-auto wrapper)
- Add viewport-relative coordinate calculation using getBoundingClientRect
- Add flip logic to show dropdown above cursor when near viewport bottom
- Dismiss dropdown on scroll/resize since fixed position doesn't track
- Add font-emoji utility class to force emoji presentation for native
  Unicode characters (star, fire, etc.) that may render as text glyphs
- Apply same fixes to MentionAutocomplete for consistency

Closes #216
2026-04-05 17:32:59 -05:00
Alex Gleason c1ec7a25ed Use published_at tag to show created/updated verbs in event action headers
Replaceable and addressable event headers now distinguish between
first publish and subsequent updates using the published_at tag:
- published_at == created_at → 'created' verb (e.g. 'created an emoji pack')
- published_at != created_at → 'updated' verb (e.g. 'updated an emoji pack')
- no published_at → 'shared' fallback for backward compatibility
2026-04-05 15:12:19 -05:00
Alex Gleason 272586d033 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-05 14:54:41 -05:00
Alex Gleason c77c098843 Add NIP-24 published_at to useNostrPublish for replaceable/addressable events
Extend useNostrPublish with an optional `prev` property on the event
template. For replaceable and addressable kinds, the hook automatically
manages published_at:

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

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

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

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

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

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

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

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

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

Closes #167

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

ContentSettings: add Interest Tabs section with inline add/remove for
hashtags and geotags. Remove buttons always visible (mobile-friendly),
X icons use strokeWidth 4.
2026-04-05 10:20:58 -05:00
Chad Curtis e3ef542875 Fix desktop tab bar overflow: add scroll arrows and auto-scroll active tab into view
On desktop, overflowing feed tabs were completely inaccessible since the
scrollbar was hidden and there was no swipe gesture. Add left/right
chevron scroll buttons that appear only on desktop when tabs overflow,
with gradient fade indicators. Also auto-scrolls the active tab into
view when switching tabs, and keeps the arc hover/active indicators
aligned during horizontal scroll.
2026-04-05 10:15:01 -05:00
Chad Curtis 3bf55990c0 Fix missing bottom border on collapsed thread expand button
When a depth-collapsed 'Show X more replies' button was the last item
in a reply sequence, it lacked a bottom border separator. Added an
isLast prop to ExpandThreadButton that adds border-b when the button
terminates the visual sequence.
2026-04-05 09:50:52 -05:00
Chad Curtis 283b31813c release: v2.6.0 2026-04-05 08:31:35 -05:00
Chad Curtis 6e1197a067 Redesign LinkFooter as compact icon+label chips 2026-04-05 08:27:37 -05:00
Chad Curtis b7d1fbf860 Fix mobile sidebar bottom links clipping into safe area 2026-04-05 08:09:21 -05:00
Chad Curtis 8fde660075 Fix Blobbi page missing bg-background/85 overlay on custom themes
DashboardShell uses fixed positioning on mobile, placing it directly
over the body background image. Without the bg-background/85 class
that MainLayout's center column provides, the raw background image
showed through unthemed. Add the same 85% opacity background overlay
used consistently across the rest of the app.
2026-04-05 07:29:06 -05:00
Chad Curtis 50c7d67928 Fix blobbi state resets caused by stale cache reads and invalidation races
All blobbi mutations now follow the read-modify-write pattern: fetch fresh
state from relays before mutating, then optimistically update the cache.
This prevents two classes of bugs:

1. Stale cache reads: mutations were reading from TanStack Query cache
   (30s staleTime) instead of relays, causing newer events to be silently
   overwritten with old stats when actions happened within the cache window.

2. Invalidation races: every mutation called invalidateCompanion() after
   the optimistic update, which triggered a refetch from relays before the
   just-published event had propagated, overwriting the optimistic data
   with the pre-mutation state.

Changes:
- ensureCanonicalBlobbiBeforeAction now fetches fresh companion + profile
  from relays (the read step) instead of using cached closure values
- useBlobbiCareActivity fetches fresh companion before streak updates
- Removed all invalidateCompanion()/invalidateProfile() calls after
  optimistic updates across every action hook
- updateCompanionEvent now updates ALL blobbi-collection query caches
  for the user, not just the specific d-tag list it was instantiated with,
  keeping BlobbiPage and companion layer caches in sync
2026-04-05 07:20:26 -05:00
Chad Curtis e355c43925 Fix cross-device settings sync and smart sync gate
Settings (theme, sidebar, etc.) changed on one device were not applied
on other devices. Three root causes:

1. NostrSync seeded lastSyncedTimestamp to remoteSync on first load,
   then the guard (remoteSync <= lastSyncedTimestamp) blocked the same
   data from being applied. Settings were never applied on page reload.

2. The encrypted settings query had staleTime: Infinity and
   refetchOnWindowFocus: false, so remote changes were never fetched.

3. useInitialSync was missing customTheme, corsProxy, faviconUrl, and
   linkPreviewUrl fields.

To avoid gating every F5 behind a spinner, a lastSync timestamp is
now persisted to localStorage whenever settings are applied. On reload,
InitialSyncGate checks this: if present, render immediately from
localStorage and let NostrSync hot-swap remote changes in background.
If absent (new browser, cleared storage), show the spinner until
settings load.
2026-04-05 06:55:05 -05:00
Chad Curtis 696204870d Fix custom theme not applying on new device login
Initial sync applied the theme mode (e.g. 'custom') from encrypted
settings but not the customTheme config (colors, fonts, background),
so the theme appeared broken on first login requiring manual setup
which also triggered an unwanted kind 16767 publish.
2026-04-05 06:33:43 -05:00
Chad Curtis 0a7e01d17c Match own-profile follow link style to the following/already-following states
Use the same icon + primary semibold text + full-width button layout
instead of muted small text with an outline button.
2026-04-05 06:17:52 -05:00
Chad Curtis dd87bc96ec Fix top nav arc overlapping letter compose picker drawer
Set hasSubHeader on LetterComposePage so the MobileTopBar uses a flat
rect instead of the down-arc variant, preventing the 20px arc overhang
from painting over the LetterEditor picker panel.
2026-04-05 06:15:34 -05:00
Chad Curtis a12d5db560 Add follow URI system with QR sharing and immersive follow page
Introduce a /follow/:npub deep link that auto-follows a user when
visited by a logged-in user, or presents an immersive business card
with a 'Follow on Ditto' CTA for logged-out visitors. The page applies
the target user's profile theme, renders their feed with infinite
scroll, and uses the same banner/avatar/arc styling as the main profile.

Add a FollowQRDialog that generates a themed QR code for the follow
URL. The QR colors are derived from the active theme: primary color
for modules (with contrast-safe darkening/lightening), and background
color for the QR background. Foreground text color is used when it is
colorful and offers significantly better contrast.

Surface the QR dialog from: own profile page (top-level button),
profile more menu, desktop sidebar account popover, and mobile drawer.
2026-04-05 06:01:48 -05:00
Alex Gleason 614634789c Merge branch 'main' of nostr://npub10qdp2fc9ta6vraczxrcs8prqnv69fru2k6s2dj48gqjcylulmtjsg9arpj/relay.ngit.dev/ditto 2026-04-04 23:17:35 -05:00
Alex Gleason 29696fa3d3 Apply nearest-neighbor scaling to small custom emoji images
Custom emoji images with natural dimensions <= 16x16 now render with
image-rendering: pixelated to preserve crisp pixels instead of blurring.

Also consolidates 6 direct <img> sites to use the shared CustomEmojiImg
component so all custom emoji rendering benefits from this behavior.
2026-04-04 22:58:42 -05:00
Chad Curtis ffc31e8e8f Merge branch 'fix/blobbi-reuse-existing-eggs' into 'main'
Fix repeated egg creation and reuse existing eggs during ceremony

See merge request soapbox-pub/ditto!158
2026-04-05 02:09:45 +00:00
filemon 720a7e91fe Base ceremony decision on actual companion stages, not onboardingDone flag
The onboardingDone flag can be true on inconsistent accounts where the
user never actually hatched an egg. Now the ceremony check always waits
for companions to load and inspects their real stages:

- Any baby/adult exists: skip ceremony, auto-fix flag if needed
- Only eggs exist: ceremony with existing egg (regardless of flag)
- No companions resolved: ceremony creates a new egg

A ceremonyCheckDone flag prevents the effect from re-firing as
companion data updates during normal use.
2026-04-04 21:06:22 -03:00
filemon 05096e2cd9 Fix duplicate egg creation on every page load during onboarding
The ceremony was triggered whenever onboardingDone was false, without
waiting for companion data to load. This caused a new egg to be
published on every page visit/refresh for users mid-onboarding.

Now the decision tree waits for companions to load before deciding:
- No profile / no pets: ceremony creates a new egg (brand new user)
- Has baby/adult: skip ceremony, auto-fix onboardingDone flag
- Has only eggs: reuse an existing egg via existingCompanion prop
- Stale pet references: treat as new user

The chosen egg is locked in a ref so mid-ceremony refreshes don't
switch eggs or create duplicates.
2026-04-04 20:37:11 -03:00
filemon 05667460eb Fix first-time egg ceremony not covering RightSidebar
Portal the first-time hatching ceremony to document.body with z-[100],
matching the subsequent hatch ceremony implementation. The overlay was
previously rendered inline inside the center column's stacking context
(relative z-0), which prevented its fixed z-50 from painting over the
sibling RightSidebar.
2026-04-04 20:15:52 -03:00
Chad Curtis b10dae7655 Persist companion position across page navigations instead of replaying entry animation 2026-04-04 17:18:24 -05:00
Chad Curtis c799b9efd6 Fix crash when rendering egg: guard against undefined allTags from CompanionData cast 2026-04-04 17:14:55 -05:00
Chad Curtis fe4834e157 Remove deprecated dead code: selector modal state, useRerollMission plumbing, unused companion prop 2026-04-04 17:11:43 -05:00
Chad Curtis 5d972249a4 Fix all ESLint errors: remove unused imports, variables, and props across 4 files 2026-04-04 17:03:32 -05:00
Chad Curtis f607a01577 Fix ambiguous Tailwind duration-[2000ms] class warning 2026-04-04 16:50:56 -05:00
Chad Curtis 1e232e6a9e Blobbi hatching ceremony: immersive egg-to-blobbi experience with redesigned care UI
Replaces the old onboarding tour with a full hatching ceremony featuring golden aura,
sparkles, typewriter dialog, and fade-to-white reveal. Redesigns the BlobbiPage with
curved arc stats, floating action bubbles, overlay drawer tabs, and responsive layout.
Adds companion pill button, simplified photo modal, and egg animation styles.
Removes the old tour system (FirstHatchTour, tour hooks, tour types).
2026-04-04 16:49:51 -05:00
Alex Gleason 431c388129 release: v2.5.2 2026-04-04 13:54:13 -05:00
Alex Gleason 72b63dac21 Set default AppConfig.client to Ditto's kind 31990 handler naddr 2026-04-04 13:30:09 -05:00
Chad Curtis be82cb9626 Propagate relay and author hints to all event fetch call sites
Wire relay URL hints (from e/E tag position [2]) and author pubkey hints
(from e/E tag position [4] or p/P tag fallback) through every component
that fetches a referenced event:

- NoteCard: use getParentEventHints, pass hints through ReplyContext
- ReplyContext: accept and forward relay/author hints to EmbeddedNote
- CommentContext: extract hints from E/A tags in parseCommentRoot,
  pass to useEvent, useAddrEvent, and EmbeddedNote
- NotificationsPage: extract hints from e tag in ReferencedNoteCard
- usePollVoteLabel: extract hints from e tag for parent poll fetch
- ComposeBox: pass quotedEvent.pubkey as authorHint to EmbeddedNote
2026-04-04 06:03:33 -05:00
Chad Curtis c2c6f711b5 Fix parent author hint extraction and useEvent query cache keying
getParentEventHints only looked at position [4] of the e tag for the parent
author pubkey, but many clients (e.g. Wisp) omit it. When the relay hint
doesn't have the event, Tier 3 (NIP-65 outbox resolution) never fired
because authorHint was undefined. Now falls back to the first p tag, which
per NIP-10 convention holds the parent author's pubkey.

Also include relays and authorHint in the useEvent queryKey so calls with
different hints aren't served stale null results from a hint-less query.
2026-04-04 05:50:21 -05:00
Chad Curtis 3fba81a7d2 Fix ancestor thread fetching to use relay hints and author outbox relays
AncestorThread was calling useEvent(eventId) without relay hints or author
hints, so ancestor events only resolved via Tier 1 (user's configured relays).
Tiers 2 (relay hints from e tags) and 3 (author's NIP-65 outbox relays) were
never activated, causing parent events on personal relays to silently fail.

Added getParentEventHints() to extract relay URL and author pubkey from NIP-10
e tags, and wired both through AncestorThread's recursive chain.
2026-04-04 05:22:28 -05:00
Chad Curtis 6f2b51197f Add option filter bars to poll voters modal with scrollable overflow and accent divider 2026-04-04 03:23:39 -05:00
Chad Curtis 00c801e9dc Add poll voter interactions, kind 1018 vote rendering, and DRY activity card refactor
Poll voters:
- Clickable voter avatar stack + vote count on polls (before and after voting)
- Voters modal showing each voter with avatar, name, option, and nevent link
- Extract VoterAvatarsButton to DRY the avatar stack pattern

Kind 1018 vote rendering:
- Register in PostDetailPage as compact activity card with parent poll ancestor
- Register in NoteCard with threaded + normal variants (user avatar, not icon)
- Register in CommentContext with Vote icon, 'a vote' label, and rich hover showing voter + option
- Extract usePollVoteLabel hook to DRY vote label resolution across 3 call sites

ActivityCard refactor:
- Extract shared ActivityCard and ActorRow from NoteCard
- Refactor reaction (kind 7), repost (kind 6/16), zap (kind 9735), and poll vote (kind 1018)
- Reuse ActivityCard in PostDetailPage for vote detail view
- Net ~250 line reduction in NoteCard
2026-04-04 03:09:20 -05:00
Chad Curtis 47e7d05cb9 Add poll voter avatars, voters modal, and kind 1018 vote detail view
- Show clickable voter avatar stack + vote count on polls (both before and after voting)
- Clicking opens a voters modal listing each voter with avatar, name, voted option, and link to their vote nevent
- Extract VoterAvatarsButton to DRY the avatar stack pattern
- Register kind 1018 in PostDetailPage so vote nevents render as compact activity cards (avatar + 'voted' + label)
- Parent poll appears as threaded ancestor above the vote card
- Use PostActionBar for vote detail action buttons
2026-04-04 02:42:19 -05:00
Chad Curtis 4ef6d1b149 Revert "Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data"
This reverts commit ed083bfdad.
2026-04-04 01:56:40 -05:00
Alex Gleason badd19d27c Reorder default sidebar: Blobbi, Badges, Emojis, Letters, Themes 2026-04-04 00:25:16 -05:00
Alex Gleason e67f90582b release: v2.5.1 2026-04-03 23:31:09 -05:00
Alex Gleason 7fa6e574f8 Fix lightbox z-index by portaling inside Lightbox itself, not just ImageGallery
The previous fix (db502b46) only portaled the Lightbox when rendered
from ImageGallery. But Lightbox is also rendered directly by
NoteContent, MediaCollage, and MagicDeckContent — all still trapped
inside the center column's z-0 stacking context (added in 8e3f778f).

Move createPortal(…, document.body) into Lightbox so every consumer
escapes the stacking context automatically.
2026-04-03 23:27:53 -05:00
Alex Gleason 9b36bf3325 release: v2.5.0 2026-04-03 23:09:20 -05:00
Alex Gleason bc1c4cb7cf Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 22:50:34 -05:00
Chad Curtis 119f684fb3 Fix sharp corners on compose box by adding rounded-2xl 2026-04-03 22:41:16 -05:00
Chad Curtis 45134ef9cc Allow file uploads in poll composer
Remove the separate pollQuestion state and poll builder branch. Poll
mode now reuses the normal textarea/preview ternary (with edit/preview
toggle, file uploads, paste handling, imeta tags) and renders poll
options and settings below it.
2026-04-03 22:29:32 -05:00
Chad Curtis db502b462c Fix lightbox appearing behind right sidebar by portaling to document.body 2026-04-03 21:52:05 -05:00
Chad Curtis ed083bfdad Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data
The default pool eoseTimeout (300ms) races and resolves shortly after the
fastest relay. Blobbi pet state and profile data are accuracy-sensitive —
stale data from a single fast relay can cause data loss when mutations
overwrite newer versions on other relays.

- Add eoseTimeout option to fetchFreshEvent and new fetchFreshEvents variant
- Update useBlobbisCollection, useBlobbonautProfile, and useBlobbiSleepToggle
  to use fetchFreshEvents/fetchFreshEvent with eoseTimeout: 1000
- Widen NostrBatcher.req() type to pass through eoseTimeout to NPool
- Gate unconditional console.log in parseBlobbiEvent behind import.meta.env.DEV
- Remove unconditional console.logs from useBlobbisCollection
2026-04-03 21:39:00 -05:00
Alex Gleason 47811f9190 Use NIP-5A canonical subdomains for nsite preview iframe origins
Instead of generating a random session ID for the iframe subdomain,
derive it from the nsite event using the NIP-5A canonical format:
- Root sites (kind 15128): npub subdomain
- Named sites (kind 35128): base36(pubkey) + d-tag subdomain

Extract hexToBase36 and getNsiteSubdomain into a shared utility
used by both NsiteCard and NsitePreviewDialog.
2026-04-03 18:37:28 -05:00
Alex Gleason ba99cdc51c Fix MIME type for nsite assets by always using extension-based detection
Blossom servers commonly return incorrect Content-Type headers (e.g. text/plain
for .js files), causing browsers to reject module scripts under strict MIME
checking. Since we always know the file path from the manifest, use guessMimeType
based on the file extension instead of trusting the Blossom response header.
2026-04-03 18:13:40 -05:00
Alex Gleason 7092f7306f Serve nsite previews directly from Blossom instead of proxying through nsite.lol gateway
NsitePreviewDialog now builds a path→sha256 manifest from the event's 'path'
tags and resolves files directly from Blossom servers (from the event's 'server'
tags, falling back to the user's configured app Blossom servers). Each fetch
request from the iframe is intercepted, the sha256 is looked up in the manifest,
and the blob is fetched from the first Blossom server that responds successfully.
Unknown paths fall back to /index.html to support SPA client-side routing.

- NsitePreviewDialog: remove nsiteUrl proxy, accept NostrEvent instead
- NsiteCard: pass event directly to dialog
- AppHandlerContent: use useAddrEvent to fetch the kind 35128 event by
  pubkey+d-tag from the 'a' tag, then pass the event to the dialog; disable
  Run button until the nsite event is loaded; remove unused hexToBase36
2026-04-03 18:10:22 -05:00
Alex Gleason 357dd56de0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 17:54:42 -05:00
Alex Gleason fadec0574a Add Run button to NsiteCard for in-app nsite preview 2026-04-03 17:49:06 -05:00
Alex Gleason 469806886a Fix card navigation firing on button/link clicks in NoteCard 2026-04-03 17:45:36 -05:00
Alex Gleason f7ab980ecd Fix nsite preview panel height using measured column rect
Replace absolute/sticky positioning with fixed + inline styles derived
from a ResizeObserver on the center column element. The panel now sits
at exactly the column's left/top/width and fills to the bottom of the
viewport, unaffected by the column's pb-overscroll padding.
2026-04-03 17:41:09 -05:00
Alex Gleason c6b5ab2284 Replace address bar and external link with app icon and name in preview nav bar 2026-04-03 17:36:17 -05:00
Alex Gleason 2231673ee6 Fix nsite preview panel to fill exactly the center column
Add CenterColumnContext to LayoutContext and expose the center column DOM
element from MainLayout via a useState ref callback. NsitePreviewDialog now
portals into that element using absolute inset-0 instead of fixed positioning
with hardcoded sidebar insets, so it always covers exactly the center column
regardless of viewport width.
2026-04-03 17:30:00 -05:00
Alex Gleason f8907475f9 Link client tag name to /:naddr on post detail page 2026-04-03 17:28:29 -05:00
Alex Gleason 4252841125 Replace dialog with fixed center-column overlay panel for nsite preview
Remove the Radix Dialog and browser chrome (back/forward/refresh/fullscreen).
The preview now renders as a portal-based fixed panel that overlays exactly
the center column using responsive left/right insets matching the sidebar
widths (sidebar:left-[300px], xl:right-[300px]). A slim nav bar at the top
shows the nsite:// URL, an external-link button, and a close button.
2026-04-03 17:25:55 -05:00
Alex Gleason ee8220c1f0 Use nsite://<name><path> in preview address bar
Separate the proxy target (nsite.lol gateway URL) from the display URL.
Pass nsiteName through to the dialog so the address bar shows a clean
nsite:// scheme with no gateway hostname.
2026-04-03 17:11:45 -05:00
Alex Gleason 11e29646a7 Show nsite:// URL in preview address bar instead of gateway URL 2026-04-03 17:09:03 -05:00
Alex Gleason a9bab7f8e8 Remove default dialog close button from nsite preview 2026-04-03 17:03:09 -05:00
Alex Gleason 0b69ab51f4 Fix Content-Type header matching in nsite preview proxy
The iframe-fetch-client does an exact equality check for "text/html",
but real servers return "text/html; charset=UTF-8". Also, the browser
fetch() API lowercases all header names while main.js checks Title-Case
keys. Fix both: re-key headers to Title-Case and strip charset params
from Content-Type values before sending them to the iframe.
2026-04-03 17:01:59 -05:00
Alex Gleason 2a32e79b13 feat: change AppConfig.client to naddr1 format, decode relay hint per NIP-89
AppConfig.client now expects a NIP-19 naddr1 string pointing to the app's
kind 31990 handler event instead of a raw 'a' tag value. useNostrPublish
decodes the naddr at publish time to extract the 31990:<pubkey>:<d-tag>
addr and any embedded relay hint, producing a fully NIP-89-compliant
client tag: ["client", <name>, <addr>, <relay-hint>].
2026-04-03 16:57:57 -05:00
Alex Gleason 39fc7549ac Add Run button and nsite preview dialog to app handler cards
When a kind 31990 app event includes an 'a' tag pointing to a kind 35128
nsite, display a 'Run' button that opens an in-app preview dialog. The
dialog embeds the nsite in a sandboxed iframe via the Shakespeare
iframe-fetch-client protocol (local-shakespeare.dev), proxying fetch
requests from the iframe to the live nsite URL so the SPA renders
without needing CORS headers on the origin server.
2026-04-03 16:53:25 -05:00
Alex Gleason 414f42e339 Add Blobbi (kind 31124) to the Ditto homepage feed 2026-04-03 16:50:17 -05:00
Alex Gleason 8e3f778f5b Improve Zapstore and app handler card display
- Rename Zapstore kind labels to include 'Zapstore' prefix across all
  label registries (NoteCard, PostDetailPage, CommentContext,
  ExternalContentHeader, NotificationsPage, extraKinds)
- Wrap Zapstore (32267, 30063, 3063) compact and detail content in
  rounded bordered cards with hover effects; remove redundant mt-2/mt-3
  margins from component roots
- Replace useLinkPreview thumbnail with metadata banner/picture in kind
  31990 app handler cards (compact and full views)
- Add pt-4 to Zapstore detail card wrappers in PostDetailPage
- Fix sticky tab bar (SubHeaderBar z-10) being painted over by card
  content: remove z-10 from AppHandlerContent inner div and add z-0 to
  the main content column in MainLayout
2026-04-03 16:20:40 -05:00
Alex Gleason bc83d08961 Upgrade Nostrify 2026-04-03 13:56:48 -05:00
Alex Gleason 7d83273410 Simplify sidebar media query to a single useQuery with inline fallback logic 2026-04-03 00:53:45 -05:00
Alex Gleason fabcb4170d Fill profile media sidebar with kind 1 fallback when kind 20 results are sparse
When fewer than 9 media-native events (kind 20, 21, 22, etc.) are found for a
profile, perform a secondary query for kind 1 events with search:media:true and
append them to fill the remaining slots. Kind 20 events are always displayed first.
2026-04-03 00:36:44 -05:00
Alex Gleason 8b824f8cc9 release: v2.4.1 2026-04-02 23:12:45 -05:00
Alex Gleason 3e429fe0b0 Add rendering for Zapstore release (kind 30063) and asset (kind 3063) events
- New ZapstoreReleaseContent component: shows app icon/name fetched from the
  linked kind 32267, version badge, channel badge, release notes, and a
  downloads section that fetches and renders each linked kind 3063 asset
- New ZapstoreAssetContent component: shows MIME-type icon, platform/arch
  badges, file size, SHA-256 hash, commit hash, supported NIPs, and APK
  certificate hashes
- Register both kinds in NoteCard, PostDetailPage, extraKinds, CommentContext,
  ExternalContentHeader, and NotificationsPage label/icon maps
- Route kind 3063 to the Zapstore relay in NostrProvider and useEvent
- Kind 3063 is excluded from feeds (display-only on direct navigation)
2026-04-02 23:09:01 -05:00
Alex Gleason a261934ab0 ci: publish zsp to relay.ditto.pub and use blossom.ditto.pub; remove --publish-server-list from nsite 2026-04-02 22:48:46 -05:00
Alex Gleason 822ff13ac3 Merge branch 'update-first-egg-tour' into 'main'
Allow first-hatch tour for migrated accounts with blobbi_onboarding_done=true

See merge request soapbox-pub/ditto!156
2026-04-03 03:42:13 +00:00
filemon afa475ecef Allow first-hatch tour for migrated accounts with blobbi_onboarding_done=true
Older accounts had onboarding_done migrated to blobbi_onboarding_done=true
before the first-hatch tour existed. When the user has exactly 1 egg and
no baby/adult companions, skip the profileOnboardingDone gate so those
accounts can still enter the tour. The localStorage isCompleted check
still prevents re-triggering for users who already finished it.

This is a temporary migration safeguard. The long-term fix is a dedicated
blobbi_first_hatch_tour_done tag.
2026-04-03 00:34:51 -03:00
Alex Gleason 853b5ead9c release: v2.4.0 2026-04-02 21:47:33 -05:00
Alex Gleason a5746ee915 Merge branch 'update-hatch-action' into 'main'
Add first-hatch tour orchestration layer (state machine + activation)

See merge request soapbox-pub/ditto!153
2026-04-03 02:43:05 +00:00
filemon fa3376ac4f Remove legacy blobbi re-export wrappers and unused duplicate hooks
No imports remained pointing at the @/lib/blobbi* or @/hooks/use{ProjectedBlobbiState,BlobbisCollection,BlobbiMigration} paths.
Delete the transitional re-exports and the dead hook copies so only
src/blobbi/core/lib/ and src/blobbi/core/hooks/ remain as the single
source of truth.
2026-04-02 23:25:52 -03:00
filemon 6f0c10fe9b Address review feedback: deduplicate blobbi.ts, remove dead props and state
- Convert src/lib/blobbi*.ts files to thin re-exports from canonical
  src/blobbi/core/lib/ sources, eliminating duplicated logic
- Remove unused emoji, title, description props from TasksPanelProps
  and their call site in BlobbiMissionsModal
- Remove dead direction state from MissionSurfaceCard (was always 'right')
- Remove unused onContinue prop from FirstHatchTourCard and call site
2026-04-02 23:10:10 -03:00
Chad Curtis 2f1bf0bca5 Fix notification dot reappearing after marking as read
Remove the invalidateQueries call in markAsRead that raced with the
setQueriesData(false) update. The invalidation triggered an immediate
refetch whose queryFn closure still held the old notificationsCursor
(from a render before the settings cache update propagated). That stale
refetch re-queried the relay with the old since value, found the same
unread events, returned true, and overwrote the false just set --
causing the dot to reappear.

The setQueriesData(false) call provides the immediate UI update. The
60-second poll and real-time subscription naturally re-evaluate once
the cursor has fully propagated.
2026-04-02 20:35:09 -05:00
filemon 9be98d9a8d Merge branch 'main' into update-hatch-action 2026-04-02 20:47:21 -03:00
filemon c4dd8e7c3d Set blobbi_onboarding_done at tour completion, not at egg adoption
Adopting a first Blobbi egg should not mark onboarding as complete —
the user still needs to go through the first-hatch tutorial. Removed
the premature blobbi_onboarding_done:'true' write from adoptPreview()
in useBlobbiOnboarding.

The flag is now set to 'true' only when the first-hatch tour reaches
its final step (egg_hatching), right after the hatch mutation succeeds.
This is the correct semantic: onboarding means the full tutorial is
done, not just that the user created a profile or adopted an egg.
2026-04-02 20:40:17 -03:00
Chad Curtis 42832b72e3 Revert dialog fly-up on mobile keyboard open
The keyboard-aware repositioning of dialogs was too aggressive and broken.
Removes the CSS rule, dialog-keyboard-aware class, and global keyboard
detector mount. The useKeyboardVisible hook is preserved for ArticleEditor.
2026-04-02 18:20:56 -05:00
filemon e77436d02a Rename onboarding_done to blobbi_onboarding_done and make profile authoritative
The onboarding completion flag was stored as a generic 'onboarding_done'
tag on the kind 11125 Blobbonaut profile, while the first-hatch tour
relied solely on device-local localStorage. This caused issues with
multi-account usage on the same browser.

Changes:
- New profiles write 'blobbi_onboarding_done' (not 'onboarding_done')
- Parsing reads 'blobbi_onboarding_done' first, falls back to old tag
- Auto-migration: useBlobbonautProfileNormalization detects old tag
  and replaces it with the new one on next profile republish
- MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES includes both tags so the
  merge logic can remove the old one during migration
- Tour activation now accepts profileOnboardingDone flag from the
  Blobbonaut profile as the authoritative completion source;
  localStorage remains a secondary fallback for in-progress UI state
- BlobbiPage passes profile.onboardingDone to the activation hook
2026-04-02 20:13:36 -03:00
filemon 302d7732ef Keep first-hatch card visible with completed state before advancing
When the user's hatch post is detected, the tour card now stays on
the 'show_hatch_card' step for 2 seconds showing a celebratory
completed state (large checkmark, 'Post shared!', 'Continuing in a
moment...') before auto-advancing to 'egg_glowing_waiting_click'.

Previously the effect called goTo() immediately on post detection,
so the checkmark was never visible — the card jumped straight to
the tap-egg phase.

Changes:
- BlobbiPage.tsx: wrap the goTo() in a 2s setTimeout
- FirstHatchTourCard.tsx: redesign completed state with centered
  checkmark, bold success text, and 'continuing' hint; remove the
  manual Continue button (auto-advance handles progression);
  update title/description to reflect the confirmed state
2026-04-02 19:28:05 -03:00
filemon b09b4938d2 Add lightweight collapsible sections to missions modal
Both Current Focus and Daily Bounties sections are now collapsible
via Radix Collapsible, defaulting to open. Section headers stay
visible when collapsed and show summary info at a glance:

- Current Focus: Hatch/Evolve badge + progress count (e.g. 2 / 5)
- Daily Bounties: coin progress + green dot for claimable count

A subtle animated chevron rotates on toggle. The collapsible
animation uses new collapsible-down/up keyframes added to the
Tailwind config (mirrors the existing accordion pattern but uses
--radix-collapsible-content-height).

Settings row stays non-collapsible to keep it simple.
2026-04-02 19:05:51 -03:00
filemon 0a0d6de111 Refactor missions modal to card-grid layout with expandable cards
- Add ExpandableMissionCard: shared component with compact collapsed
  state (icon + title + progress ring) and full-width expanded state
  with details, progress bar, action links, claim buttons
- Rework TasksPanel as a 2-col (3-col on sm+) grid of task cards;
  each card maps its task id to a specific lucide icon (Palette,
  Droplets, MessageSquare, Heart, UserPen, Activity)
- Rework DailyMissionsPanel as the same grid; each card maps its
  action type to an icon (Utensils, Moon, Camera, Mic, etc.)
- Only one card expanded at a time per section
- Add MissionTypeLegend popover in the header (? icon) explaining
  Daily / Hatch / Evolve mission types with color-coded dots
- Pass category prop (hatch | evolve | daily) through to cards for
  per-type accent colors (sky / violet / amber)
- Keep all existing behavior: claim, reroll, stop, CTA buttons
2026-04-02 18:47:29 -03:00
filemon 4e9b893822 Redesign missions modal with lighter quest-board aesthetic
- Remove all Collapsible wrappers; sections are always visible
- Restructure layout: Current Focus (hatch/evolve) on top, Daily
  Bounties below, settings toggle at footer
- Flatten TasksPanel: remove Card/CardHeader chrome, use minimal
  rows with soft rounded backgrounds and inline action links
- Lighten DailyMissionsPanel: compact mission rows, smaller claim
  buttons, muted claimed state, no heavy border cards
- Add empty focus state with Compass icon when no active process
- Sticky header with quest-themed subtitle
- ~100 fewer lines across the three files
2026-04-02 17:52:46 -03:00
filemon c60e87ad65 Merge branch 'main' into update-hatch-action 2026-04-02 16:49:03 -03:00
Alex Gleason 8e07ad515a Merge branch 'improve-baby-tasks' into 'main'
Broaden evolve 'Edit Wall' mission to accept profile metadata edits (kind 0)

See merge request soapbox-pub/ditto!155
2026-04-02 19:28:00 +00:00
filemon b4c4b8eb21 Rename wall-specific identifiers to profile-oriented naming
- KIND_WALL_EDIT → KIND_PROFILE_TABS
- wallEditEvents → profileTabsEvents
- edit_wall task id → edit_profile
- Split completion check into hasTabsEdit / hasMetadataEdit / hasProfileEdit
2026-04-02 15:52:54 -03:00
filemon 23ee6f1196 Broaden evolve 'Edit Wall' mission to accept profile metadata edits (kind 0)
The mission now completes when the user either:
- Edits custom profile tabs (kind 16769, existing behavior)
- Updates profile metadata (kind 0, new)

Both paths require the event's created_at to be after the evolution
start timestamp (stateStartedAt), so pre-existing events won't
auto-complete the task.

Updated UI copy: 'Edit Your Profile' / 'Update your profile info or
customize your profile tabs'.
2026-04-02 15:32:36 -03:00
Alex Gleason 4b97baa428 Merge branch 'exclude-text-from-media-sidebar' into 'main'
Exclude kind 1 and kind 1111 from profile Media sidebar

See merge request soapbox-pub/ditto!152
2026-04-02 16:51:27 +00:00
Alex Gleason c8e844a19a release: v2.3.1 2026-04-02 10:25:17 -05:00
Chad Curtis 205a252cac Fix slug collision check blocking edits to existing articles
Replace isEditMode guard with originalSlug comparison so the collision
check is skipped when republishing an article with the same slug it was
loaded with, but still runs if the user changes the slug to one that
would overwrite a different article.
2026-04-02 07:48:14 -05:00
Chad Curtis ad604eae68 Improve dialog UX on mobile: rounded corners, button spacing, keyboard awareness
- Add rounded-xl to Dialog and AlertDialog (was sm:rounded-lg only)
- Add consistent gap-2 to footer buttons on mobile (was no gap)
- Use w-[calc(100%-2rem)] for mobile side margins
- Push dialogs to top of viewport only when keyboard is visible via
  .keyboard-visible class on <html>, toggled by useKeyboardVisible
- Mount useKeyboardVisible globally in MainLayout so the class is
  always available for CSS-only consumers
2026-04-02 05:10:07 -05:00
Chad Curtis 57064b4f40 Save draft on blur and show cloud sync indicator
- Trigger silent draft save when title or editor loses focus
- Add onBlur prop to MilkdownEditor, wired to both WYSIWYG and source textarea
- Mark saved immediately after local write instead of waiting for relay
- Show persistent cloud icon in status; pulses while relay sync is in flight
2026-04-02 05:10:07 -05:00
Chad Curtis bb7b8da581 Always save drafts locally so they appear immediately in My Articles
Previously, drafts were only saved to localStorage on relay failure.
If the relay accepted the event but hadn't indexed it yet for queries,
the draft would show 'Saved' but not appear under My Articles. Now
we always persist locally first for instant visibility, then sync to
the relay in the background.
2026-04-02 05:10:07 -05:00
Chad Curtis 5683f6ea1e Fix source mode toggle clearing editor content
initialValueRef was only set once on mount, so toggling back from
source mode reinitialized Milkdown with stale content. Keep
initialValueRef and lastExternalValue in sync with the current value
so remounts and the replaceAll guard work correctly.
2026-04-02 05:10:07 -05:00
Chad Curtis 61c606822a Fix crash when editing in markdown source mode
The replaceAll effect tried to access editorViewCtx while in source
mode where the ProseMirror view isn't mounted, causing a 'Context
editorView not found' error. Skip the sync when sourceMode is active
and add a try/catch for the initial render race.
2026-04-02 05:10:07 -05:00
Chad Curtis bc12331cd4 Keep tab bar in article editor but make it non-sticky on mobile write mode
Add ARC_OVERHANG_PX spacer div after the header to prevent arc
overlapping content, matching the pattern used across other pages.
2026-04-02 05:10:07 -05:00
Chad Curtis 2478bf1c66 Improve mobile article editor UX when virtual keyboard is open
- Hide tab bar in write mode on mobile, replace with slim back+title header
- Hide publish FAB when keyboard is visible (was floating over content)
- Collapse metadata (summary, slug, tags) behind a 'Details' toggle on mobile
- Hide header image and stats bar when keyboard is up to maximize writing area
- Add useKeyboardVisible hook using Visual Viewport API
2026-04-02 05:10:07 -05:00
filemon ade9eb4999 Merge branch 'main' into update-hatch-action 2026-04-02 06:17:41 -03:00
Alex Gleason 213bbb21c1 release: v2.3.0 2026-04-02 03:57:37 -05:00
Alex Gleason dd3ae4da4e npm audit fix 2026-04-02 03:52:24 -05:00
Alex Gleason 681d2ab90b Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-02 03:51:09 -05:00
Alex Gleason 24a645277e Fix custom emoji stretching by adding object-contain to all emoji images
Custom emoji images with non-1:1 aspect ratios were being stretched
into a square. Added object-contain to preserve natural aspect ratio
within the bounding box. Moved text sizing classes to parent containers
for reaction emoji bubbles so unicode emojis still size correctly.
2026-04-02 03:50:50 -05:00
Chad Curtis fa34922cce refactor: harden article editor — encryption, mobile UX, deduplication, source toggle
- Encrypt drafts with NIP-44 via NIP-37 (kind 31234) instead of
  plaintext kind 30024
- Fix slug auto-generation overwriting manual edits
- Guard auto-save state setters against unmount
- Deduplicate save logic, load handlers, tag extraction, and types
  via shared ArticleFields/parseArticleEvent helpers
- Replace derived state (wordCount/readingTime) with useMemo
- Mobile UX: sticky toolbar, touch-friendly header image swap,
  adaptive tooltips (pointer:fine only), FAB bottom clearance,
  responsive editor min-height
- Editor placeholder: hide on focus, handle trailing whitespace
- Tighten editor padding and paragraph spacing
- Add raw markdown source toggle (Eye/EyeOff) in toolbar
- Shrink slug/tag fields, consistent sizing
2026-04-02 03:48:10 -05:00
Chad Curtis 89c71ed073 Merge branch 'feat/article-editor' into 'main'
feat: add in-app article editor with Milkdown WYSIWYG

See merge request soapbox-pub/ditto!150
2026-04-02 08:47:37 +00:00
filemon 0f02563d3a Add mission card dismiss/toggle and fix More menu for hidden bar items
- Mission surface card now has an X dismiss button (onHide prop)
  that hides it via localStorage ('blobbi:mission-card-visible')
- BlobbiMissionsModal gains a 'Show mission card on main page'
  toggle at the bottom, reflecting the same preference
- Both controls share the same state: hiding from the card or
  toggling from the modal are equivalent
- More dropdown now conditionally shows items: if an action
  (Blobbies, Items, Missions, Photo, Companion) is visible in
  the bottom bar, it is skipped in More to avoid duplication;
  if removed from the bar, it appears in More so no action
  becomes inaccessible
2026-04-02 05:26:21 -03:00
Alex Gleason f49909dedf Close mobile drawer when clicking footer links (Changelog, Privacy) 2026-04-02 03:23:13 -05:00
Alex Gleason ab43225f0c Remove Nostr protocol jargon from changelog and add rule to release skill 2026-04-02 03:14:01 -05:00
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
filemon 38630be23d Add customizable bottom bar, mission surface card, and action bar editor
Bottom bar simplification:
- Default to 3 visible items: Blobbies (left), Main Action (center),
  More (right). Items/Missions/Photo moved into More dropdown.
- All existing actions (Set as Companion, Evolve/Hatch, View Blobbi,
  dev tools) remain in More with existing guards.
- 'Edit action bar' entry in More opens the new editor.

Editable action bar preferences:
- New preference model (action-bar-preferences.ts) with localStorage
  persistence, validation, and migration support.
- Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion.
- Up to 3 custom visible slots (Main Action + More are fixed).
- Each slot can be shown/hidden, reordered, or highlighted.
- ActionBarEditor modal for editing with reset-to-default option.

Mission surface card:
- MissionSurfaceCard renders below the Blobbi visual, above the bar.
- Shows one mission at a time with badge (Hatch/Evolve/Daily),
  progress bar, description, and coin reward for dailies.
- Priority: hatch/evolve tasks first, then unclaimed daily missions.
- Auto-rotates every 5s when multiple cards; manual tap cycles.
- 'View all missions' link opens existing missions modal.
- Hidden during first-hatch tour (preserves tour behavior).
2026-04-02 04:55:00 -03: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
filemon 9b8cff63da Polish first-hatch tour: center click hint over egg, keep crack visible during opening
- Move click hint emoji to centered overlay with larger size (text-4xl)
  so users clearly see it over the egg, not tucked in a corner
- Keep crack overlay visible during egg_opening state by including
  'opening' in tourShowCrack and mapping it to crack level 3
- The crack SVG lives inside the shell div, so it inherits the
  opening animation (scale/blur/fade) and disappears with the shell
- Suppress shake animation during opening so it doesn't conflict
  with the smooth open sequence
2026-04-02 04:12:00 -03: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
filemon e13473809d Fix egg crack progression, companion auto-assignment, and add dev tour controls
- Replace full-width crack with stage-specific SVG paths that grow
  outward from the egg center: level 0 shows a small central cluster,
  level 1 expands left/right with branches, level 2 reaches further
  with more fracture detail, level 3 spans near-full width
- Remove current_companion assignment during egg adoption so eggs
  are never auto-set as the floating companion
- Add first-hatch tour dev controls to BlobbiDevEditor: skip post
  requirement, restart tour, and reset-to-egg+tour buttons
2026-04-02 03:52:54 -03: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
filemon 00a9ad20de Merge branch 'main' into update-hatch-action 2026-04-02 03:13:16 -03: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
Mary Kate Fain d28364531b Exclude kind 1 and kind 1111 from profile Media sidebar
Give ProfileRightSidebar its own query using a kind whitelist
(20, 21, 22, 34236, 36787, 34139, 30054, 30055) instead of
relying on the parent's search-based media query. This ensures
the desktop sidebar only shows media-native events, excluding
kind 1 text notes and kind 1111 comments at the query level.

The Media tab continues to use the broader useProfileMedia hook
with search: 'media:true' and is unaffected.
2026-04-01 18:34:32 -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
filemon f3eb4adba5 Fix first-hatch tour: full flow wiring, progressive cracks, hatch reveal
Tour flow fixes:
- Rename show_hatch_modal -> show_hatch_card (no longer a modal)
- Remove unused egg_ready_hint, await_create_post, tour_rewards_reveal,
  tour_set_companion_hint steps; simplify to 9 focused steps
- Auto-advance from idle -> show_hatch_card immediately
- Card stays visible through show_hatch_card + glowing + crack stages
- Post detection advances to egg_glowing_waiting_click automatically
- Crack stages are manual (1 click per stage, 3 clicks total)
- egg_opening and egg_hatching are auto-advance with timers
- egg_hatching triggers the actual useBlobbiHatch mutation + completes tour

Egg visual improvements:
- Initial crack (hairline) shown from show_hatch_card step onward
- Progressive crack SVG: level 0 (hairline) -> 1 (branches) -> 2 (more)
  -> 3 (full fracture pattern with large splits)
- Auto-wiggle every 2.5s during show_hatch_card and glowing_waiting_click
- Shell opening animation (scale + brightness + blur -> fade out)
- Bright white glow during opening/hatching for light burst effect
- onTourEggClick callback threaded through BlobbiStageVisual -> BlobbiEggVisual -> EggGraphic

Fake pointer hint:
- After 10s on egg_glowing_waiting_click, show bouncing pointer emoji
- Repeats every 5s if user doesn't click
- Disappears immediately on egg click

Layout during tour:
- Inline card rendered ABOVE stats section, directly below egg
- Stats section hidden entirely during first-hatch tour
- Dashboard controls + bottom bar + inline activities still hidden

FirstHatchTourCard improvements:
- Accepts currentStep prop for adaptive messaging
- Post step: shows mission card with required phrase + Create Post
- Click steps: shows 'Tap {Name} to hatch!' with tap icon hint
- Post completed state: shows checkmark + Continue button
2026-04-01 19:34:54 -03: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 0487586af9 Replace first-hatch modal with inline card, hide dashboard controls during tour
UX change: the first-hatch experience is now a focused onboarding screen
instead of a modal interruption.

Layout during first-hatch tour:
- Egg visual (top, with tour animations)
- Stats (if any visible)
- FirstHatchTourCard inline below stats (mission + post CTA)
- No floating hero controls (camera, info, companion, incubation)
- No bottom action bar (blobbies, missions, actions, shop, inventory)
- No inline activity area (music, sing)

The page feels like a dedicated guided flow rather than a dashboard
with overlays. Normal dashboard controls return after tour completion.

Architecture: clean branch in BlobbiDashboard render --
isFirstHatchTourActive gates visibility of controls/bar/activities.
The inline card lives at the same level as other content sections.
The first egg is treated as already in the hatch onboarding path
without requiring the normal 'start incubation' entry point.
2026-04-01 18:11:48 -03:00
filemon 2c737ca322 Wire first-hatch tour into BlobbiPage with post phrase update and egg visuals
Tour integration:
- Call useFirstHatchTour + useFirstHatchTourActivation in BlobbiDashboard
- Auto-advance: idle -> egg_ready_hint (immediate) -> show_hatch_modal (3s)
- Poll for valid hatch post during show_hatch_modal/await_create_post
- On post detected, advance to egg_glowing_waiting_click
- Missions button opens tour modal instead of normal missions during tour
- Hide incubation button during tour (tour handles the flow)
- Badge shows tour-specific remaining count (1 post mission)

Post phrase update:
- New format: 'Posting to hatch {Name} #blobbi' (was: 'Hello Nostr! Posting to hatch #name #blobbi #ditto #nostr')
- Update isValidHatchPost to check for phrase anywhere in content
- Add buildHatchPhrase helper
- Simplify BlobbiPostModal validation and tag extraction

Egg visual layer:
- Add EggTourVisualState type ('idle' | 'ready_hint' | 'glowing_waiting_click')
- Thread tourVisualState prop: BlobbiStageVisual -> BlobbiEggVisual -> EggGraphic
- ready_hint: auto-wiggle every 2.5s using existing egg-tap-wiggle animation
- glowing_waiting_click: enlarged pulsing glow via new egg-tour-glow CSS animation
- Add reduced-motion support for new animation

FirstHatchTourModal component:
- Shows during show_hatch_modal/await_create_post steps
- Single mission: create a hatch post with the required phrase
- Continue button appears when post is detected
2026-04-01 17:42:12 -03:00
filemon c9823055fd Add first-hatch tour orchestration layer (state machine + activation)
New src/blobbi/tour/ module with:
- tour-types.ts: Generic TourStepDef/TourState/TourActions types, plus
  FirstHatchTourStepId enum and ordered FIRST_HATCH_TOUR_STEPS array
- useFirstHatchTour: Step-based state machine with localStorage
  persistence, advance/goTo/complete/reset actions, and derived
  booleans (isStep, isAnyStep, currentStepDef) for UI consumption
- useFirstHatchTourActivation: Precondition guard that auto-starts
  the tour when: exactly 1 Blobbi, egg stage, no baby/adult, not
  yet completed
- Barrel index.ts exporting all types, hooks, and constants

No visual/UI changes yet -- this is the orchestration foundation
that rendering layers will plug into.
2026-04-01 16:33:57 -03:00
filemon d2cd5f22bf Merge branch 'main' into update-hatch-action 2026-04-01 15:54:34 -03: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
Derek Ross 2d1a3ff6f5 chore: update package-lock.json after rebase 2026-04-01 14:03:17 -04:00
Derek Ross 90bd10d87a fix: remove unused imports and variables in ArticleEditor 2026-04-01 14:01:46 -04:00
Derek Ross 280bcbd5ab fix: prevent Save Draft button wrapping to second line on mobile 2026-04-01 14:01:46 -04:00
Derek Ross 65ecfca05e fix: show bottom navigation bar on article editor page 2026-04-01 14:01:46 -04:00
Derek Ross 91f5afc110 fix: default logged-out users to global tab on kind-specific feed pages
Kind-specific pages (articles, photos, videos, etc.) clamped the feed tab
to 'follows' for all users, but the follows query requires a logged-in
user. Logged-out users saw infinite skeleton loading with no way to switch
tabs. Now defaults to 'global' when no user is present.
2026-04-01 14:01:46 -04:00
Derek Ross 1c980fb039 refactor: simplify article editor to New/My Articles tabs with inline metadata
Remove Details tab and Save header icon. Metadata (image, summary, slug,
tags) now sits inline between title and editor body like Medium. Save Draft
button moved to bottom of compose form. Header tabs renamed to New and
My Articles.
2026-04-01 14:01:46 -04:00
Derek Ross e93c665123 feat: add in-app article editor with Milkdown WYSIWYG
Replace external Inkwell link with a built-in article creation experience.
Uses Milkdown editor with tabbed UI (Write/Details/Drafts) matching the
letters compose pattern, FAB publish button, relay+local draft support,
and kind 30023/30024 publishing.
2026-04-01 14:01:46 -04: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
Chad Curtis f7821451c7 release: v2.2.1 2026-03-28 10:15:52 -05:00
Chad Curtis 9056b43696 Highlight flushed posts, fix new-posts pill positioning, fix desktop tab gap
- New posts flushed from the stream buffer now briefly highlight with a
  primary-tinted fade animation so users can see what appeared
- New-posts pill uses responsive CSS (new-posts-pill utility) so it sits
  correctly below the SubHeaderBar on both mobile and desktop
- SubHeaderBar desktop padding moved inside the inner wrapper so the arc
  background extends to the viewport edge, eliminating the gap above tabs
2026-03-28 10:12:11 -05:00
Chad Curtis cdf54e9eff Fix 'new posts' button on search page not loading posts
The button only called window.scrollTo() and relied on the scroll event
listener to auto-flush the stream buffer. This failed when smooth
scrolling didn't fire reliable scroll events (especially on mobile/
Capacitor WebView). Now explicitly calls flushStreamBuffer() on click.
2026-03-28 09:51:30 -05:00
Chad Curtis 7a49e9646c Bypass nudge for encrypt and decrypt, remove phase-transition toast
Encrypt and decrypt operations now call the signer directly without
nudge/timeout/retry wrapping. The sign operation already provides
the user-facing nudge when approval is needed, so the encrypt nudge
was redundant noise. Phase-transition toast and related constants
removed as dead code.
2026-03-28 09:43:43 -05:00
Chad Curtis 2189f5e7c4 Fix MobileTopBar double-opacity: remove bg from header, use dedicated safe-area fill
The header had bg-background/85 plus the ArcBackground SVG fill-background/85
stacking to ~98% opacity. Now the safe-area padding zone gets a single-layer
bg-background/85 fill div (same pattern as pinned SubHeaderBar), and the
ArcBackground provides the only fill for the content area.
2026-03-28 09:39:09 -05:00
Chad Curtis 2822b4c159 Remove decrypt nudge, fix pinned tab safe area fill
- Decrypt operations now bypass signerWithNudge entirely (no nudge toast)
- Pinned SubHeaderBar safe area uses a separate fill div matching
  MobileTopBar's bg-background/85, avoiding double-opacity stacking
  with the ArcBackground SVG below
2026-03-28 09:37:28 -05:00
Chad Curtis 3dd2591709 Fix pinned tab arc, signer toast spam, new-posts pill position, toast swipe
- Pinned SubHeaderBar uses safe-area-inset-top (top offset) instead of
  safe-area-top (padding) so the arc stays flush with the tab content
- Throttle signer nudge toasts (8s cooldown) to prevent rapid-fire storm
  when relay connection is unstable
- New posts pill fades out when nav hides instead of translating, avoiding
  it floating in the safe area zone
- Signer toasts use finite duration (120s) so Radix swipe-to-dismiss works
- Lower toast swipe threshold from 50px to 30px for easier dismissal
2026-03-28 09:32:08 -05:00
Chad Curtis ab2145ffe9 Fix mobile UX: sticky safe area, page padding, stream buffering, media CW
- SubHeaderBar pinned mode adds safe-area-top padding when nav is hidden
- Increase signer nudge delay to 10s for decrypt ops (reduces false triggers on Amber)
- Add arc overhang spacer to Search, Notifications, and Profile pages
- Add PageHeader to Search page for consistent top-level layout
- Buffer streamed posts when user is scrolled down to prevent scroll jumps
- Show 'N new posts' pill that tracks SubHeaderBar position and nav state
- MediaCollage respects NIP-36 content warnings (blur/hide/show policy)
- Normalize main element classes across Profile and Notifications pages
2026-03-28 09:16:00 -05:00
Chad Curtis 3ee880d1dd release: v2.2.0 2026-03-28 08:00:41 -05:00
Chad Curtis 586e103161 Merge branch 'fix/zap-primal-users' into 'main'
Fixing zap Primal users without error

See merge request soapbox-pub/ditto!110
2026-03-28 12:55:40 +00:00
Chad Curtis 5776bf2a51 Merge branch 'fix/remote-signer-ux-improvements' into 'main'
feat: remote signer UX improvements for Amber/NIP-46 users on Android

See merge request soapbox-pub/ditto!128
2026-03-28 12:51:15 +00:00
Chad Curtis ceb442ebf1 Merge remote-tracking branch 'origin/main' into fix/remote-signer-ux-improvements
# Conflicts:
#	src/hooks/useCurrentUser.ts
#	src/index.css
2026-03-28 07:47:26 -05:00
Chad Curtis e6a2bdc65f Clean up nsec paste guard toast wording for clarity 2026-03-28 07:37:59 -05:00
Chad Curtis c9205adbab Merge branch 'fix/nsec-paste-guard' into 'main'
feat: block nsec private key paste with warning

See merge request soapbox-pub/ditto!101
2026-03-28 12:37:28 +00:00
Chad Curtis 29d56daab3 fix: relay page arc header with inline NIP-11 info panel
- Use PageHeader + SubHeaderBar arc format matching other feed pages
- Add arc overhang spacer for consistent feed padding
- Move NIP-11 relay info into an inline expanding panel (maxHeight transition)
- Info toggle button in PageHeader top-right corner
- Accept string | undefined in useRelayInfo hook signature
- Register useLayoutOptions({ hasSubHeader: true }) for mobile nav
2026-03-28 07:28:32 -05:00
Chad Curtis 4d7ac5e619 Merge branch 'feat/relay-info' into 'main'
Adding NIP-11 relay information on network settings page

See merge request soapbox-pub/ditto!106
2026-03-28 12:11:44 +00:00
Chad Curtis fb3686fef4 Merge branch 'fix/embedded-note-link-preview' into 'main'
Show link preview cards in quoted posts

See merge request soapbox-pub/ditto!107
2026-03-28 12:05:15 +00:00
Chad Curtis ed14ef0cd9 Merge branch 'fix/notifications-136' into 'main'
Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts

Closes #136

See merge request soapbox-pub/ditto!102
2026-03-28 12:04:20 +00:00
Chad Curtis fd90f90cbb Restore client-side reply count seeding with correct nip85-event-stats cache key 2026-03-28 06:59:01 -05:00
Chad Curtis b6cee104b9 Filter Mentions tab by literal nostr: URI mentions in content 2026-03-28 06:49:19 -05:00
Chad Curtis 5c6df95734 Fix review issues: remove duplicate subscription, fix zapAmount regression, remove dead cache seeding 2026-03-28 06:40:58 -05:00
Chad Curtis 8941aca968 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/notifications-136
# Conflicts:
#	src/hooks/useHasUnreadNotifications.ts
#	src/hooks/useTrending.ts
#	src/pages/NotificationsPage.tsx
2026-03-28 06:32:45 -05:00
Chad Curtis e1348f782e refactor: deduplicate theme dark/light detection into colorUtils
Extract getBackgroundThemeMode() and getBackgroundHex() into colorUtils.ts,
replacing duplicated CSS variable reading and luminance calculations in
EmojiPicker, main.tsx status bar, and TweetEmbed. Also fixes TweetEmbed
incorrectly treating custom themes as always light.
2026-03-28 06:27:21 -05:00
Chad Curtis b652976784 Merge branch 'fix/emoji-picker-autofocus' into 'main'
Fix: Auto-focus search box when emoji picker opens

Closes #162

See merge request soapbox-pub/ditto!114
2026-03-28 11:08:30 +00:00
Chad Curtis 530c0681d0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/emoji-picker-autofocus
# Conflicts:
#	src/components/EmojiPicker.tsx
2026-03-28 06:01:21 -05:00
Chad Curtis e38f57f823 Merge branch 'fix/hashtag-query-eq' into 'main'
Fixing query case variants for tag feed parity with search

See merge request soapbox-pub/ditto!111
2026-03-28 10:57:46 +00:00
Chad Curtis f3b9eb9f73 Merge branch 'fix/disable-notifications' into 'main'
Fix notification preferences not filtering based on settings

See merge request soapbox-pub/ditto!93
2026-03-28 10:55:25 +00:00
Chad Curtis bd7be9590a Merge branch 'fix/feed-header-ordering' into 'main'
Fix: Consistent feed header ordering — title above tabs on all feed pages

Closes #153, #155, #156, #157, #158, and #159

See merge request soapbox-pub/ditto!112
2026-03-28 10:51:29 +00:00
Chad Curtis e1fa43c9f0 fix: add arc overhang spacer to BadgesPage to match other feeds 2026-03-28 05:48:07 -05:00
Chad Curtis ccb0d9ec71 fix: add background to PageSkeleton so it matches feed appearance 2026-03-28 05:46:41 -05:00
Chad Curtis eca4a5ba77 fix: PageHeader padding, opacity, and BooksPage search bar overlap
- Equal vertical padding (py-4) on PageHeader for balanced spacing
- Add bg-background/85 to PageHeader to match SubHeaderBar opacity
- Add top padding to BookSearchBar so it clears the arc overhang
2026-03-28 05:39:48 -05:00
Chad Curtis 6dd29c571f refactor: extract useInfiniteScroll hook and deduplicateEvents utility
Consolidate duplicated infinite-scroll boilerplate (auto-fetch page 2,
IntersectionObserver, scroll trigger) into a shared useInfiniteScroll
hook, and extract the flatten+dedup pattern into deduplicateEvents.

Also migrate BadgesPage and ThemesPage to use useFeedTab for
consistent tab persistence across all feed pages.
2026-03-28 05:31:49 -05:00
Chad Curtis 28f1e2b517 Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering 2026-03-28 05:14:26 -05:00
Chad Curtis 21374b2cb4 Make vines desktop immersive like mobile, with floating tab bar and back button 2026-03-28 05:13:21 -05:00
Chad Curtis ada87468cc Merge branch 'main' of gitlab.com:soapbox-pub/ditto into fix/feed-header-ordering
# Conflicts:
#	src/pages/BadgesPage.tsx
2026-03-28 05:00:19 -05:00
Chad Curtis 0a4b488d69 Merge branch 'fix/vines-feed-header' into 'main'
Fix: Add Vines feed section header with info link to divine.video

Closes #154

See merge request soapbox-pub/ditto!113
2026-03-28 09:57:38 +00:00
Chad Curtis aa257b34ec Fix bottom nav flashing during vine swipe transitions
Remove the activeVinePlaying reset on index change. The old card's
onPlayingChange is already undefined after re-render, and the new
card's autoplay fires onPlay directly, so the state stays consistent
through transitions without a brief false→true flash.
2026-03-28 04:43:52 -05:00
Chad Curtis c48e6c7123 Add safe area insets to vine card overlay UI
Offset the bottom info strip, action sidebar, and mute button by
env(safe-area-inset-bottom) so they clear the home indicator on
notch/island devices. Applied to both the live UI and loading
skeleton.
2026-03-28 04:43:14 -05:00
Chad Curtis 827bc4b836 Show bottom nav when vine is paused, hide during playback
Lift playing state from VineCard to VinesFeedPage via onPlayingChange
callback. hideBottomNav is now driven by whether the active vine is
playing, so users can navigate when paused. Reset playing state on
swipe so the nav briefly appears while the next vine loads. Remove
noArcs so the bottom nav renders with its normal arc appearance.
2026-03-28 04:41:40 -05:00
Chad Curtis c7d115f873 Make vines page fully immersive on mobile
Hide the mobile top bar and bottom nav entirely on the vines page,
replacing them with a floating TikTok-style tab bar that overlays
directly on the video. The menu button (hamburger) is embedded in
the floating bar so users can still access navigation.

- Add hideTopBar and hideBottomNav layout options
- Add DrawerContext so pages can open the mobile drawer directly
- Move floating tab bar outside the scroll container to fix
  IntersectionObserver index tracking (autoplay on next video)
- Simplify vine-slide-height CSS to use full 100dvh
2026-03-28 04:36:41 -05:00
Alex Gleason 45c585a27d fix: add Vines feed section header with info link to divine.video
Fixes #154
2026-03-28 04:20:30 -05:00
Alex Gleason dc3fe02767 Upgrade Radix UI packages to fix infinite render loop with React 19
@radix-ui/react-popper 1.2.4 had a useEffect with no dependency array
that called setState (onAnchorChange) on every render, causing an
infinite loop. Fixed in 1.2.8 by tracking the previous anchor value
in a ref and only calling setState when it changes.

Upgraded all Radix packages that depend on react-popper:
- react-tooltip 1.2.4 -> 1.2.8
- react-popover 1.1.11 -> 1.1.15
- react-dropdown-menu 2.1.12 -> 2.1.16
- react-context-menu 2.2.12 -> 2.2.16
- react-hover-card 1.1.11 -> 1.1.15
- react-select 2.2.2 -> 2.2.6
- react-menubar 1.1.12 -> 1.1.16
- react-navigation-menu 1.2.10 -> 1.2.14
2026-03-28 02:29:17 -05:00
Lemon a1ef06510e Remove stale DEFAULT_KINDS fallback, skip polling until JS configures kinds 2026-03-28 00:20:21 -07:00
Alex Gleason 56002c68ca Add eslint rule to prohibit import.meta.glob usage 2026-03-28 02:19:55 -05:00
Alex Gleason 30bd73f8f9 Replace import.meta.glob with inlined SVG constants for Shakespeare compatibility
import.meta.glob is Vite-only and crashes in Shakespeare's esbuild bundler.
Generated baby-svg-data.ts and adult-svg-data.ts with SVG content as template
literal constants, keeping the existing resolver API unchanged.
2026-03-28 02:18:26 -05:00
Lemon 9d8a30f678 Add badge awards push notification template and pref key mapping 2026-03-28 00:13:21 -07:00
Lemon 2b0b99d598 Fix letters push toggle not propagating to nostr-push server 2026-03-28 00:07:28 -07:00
Lemon 8551852c9d Add badges and letters kinds to shared notificationKinds helper 2026-03-28 00:00:20 -07:00
Lemon 7ade0eaeb1 Clean up notification follow-filter code: remove dead ref, fix filter typing 2026-03-27 23:58:31 -07:00
Lemon debdbf770b Respect 'only from people I follow' in web push notifications
Send authors: ['$contacts'] in the nostr-push subscription filter
when onlyFollowing is enabled. The nostr-push server resolves this
macro to the user's kind 3 follow list. Toggling the setting off
removes the authors filter so all notifications are delivered again.
2026-03-27 23:58:31 -07:00
Lemon 01976685e8 Respect 'only from people I follow' in unread notification dot
Apply the same authors filter as useNotifications so the nav dot
won't appear for notifications from non-followed accounts.
2026-03-27 23:58:31 -07:00
Lemon bac5d71480 Respect 'only from people I follow' in native Android notifications
Pass followed pubkeys through the Capacitor plugin to the native
polling service. When onlyFollowing is enabled, the relay query
includes an authors filter so only events from followed accounts
trigger native Android notifications.
2026-03-27 23:58:31 -07:00
Lemon 0ad655d1cf Fix notification preferences not filtering push notifications or unread dot
Disabled notification types (e.g. reactions) still triggered push
notifications and showed the unread dot indicator, even though the
notification tab correctly filtered them out.

Three root causes fixed:
- useHasUnreadNotifications now uses getEnabledNotificationKinds to
  only query for enabled types, preventing phantom unread dots
- NotificationSettings now syncs type preference changes to the
  nostr-push server via updateSubscription (is_active toggle)
- Native Android poller now receives enabled kinds from the JS layer
  and uses them in the relay filter instead of hardcoded kinds
2026-03-27 23:58:31 -07:00
Chad Curtis a526e301da Fix MobileTopBar arc lingering when hidden on pages without tabs
The hide transform was missing the 20px arc overhang, so the bottom
curve remained visible after the bar slid up. Match the nav-hidden-slide
approach used by SubHeaderBar.
2026-03-28 00:46:29 -05:00
Chad Curtis 9c16c6df40 Fix MobileTopBar arc position with safe-area inset on native
The ArcBackground was positioned over the entire header including the
safe-area padding, causing the arc curve to sit too high on native apps.
Wrap content in a relative container so the arc only covers the content
area, and add bg-background/85 on the header to fill the safe-area region.
2026-03-28 00:46:29 -05:00
Chad Curtis 3220e9482b Fix profile and compose letter tabs hiding with mobile top bar
Add 'pinned' prop to SubHeaderBar that transitions top to 0 instead
of sliding off-screen when the nav hides. Applied to ProfilePage tabs
and ComposeLetterSheet toolbar.
2026-03-28 00:46:29 -05:00
Alex Gleason 9a033d7f91 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:38:33 -05:00
Chad Curtis 2329458a84 Fix settings letters section, drawer background/z-index, and top nav scroll hide
- Remove letters section from settings page (accessible from letters page)
- Add background to letter editor drawer panel
- Fix drawer z-index so letter content doesn't bleed through
- Fix compose sheet SubHeaderBar top offset in overlay context
- Hide top bar and sub-header tabs together on scroll down
2026-03-28 00:36:07 -05:00
Alex Gleason c613a7aedd npm audit fix 2026-03-28 00:30:35 -05:00
Alex Gleason d4d502f418 Loosen engines constraint to node >=22 2026-03-28 00:30:14 -05:00
Alex Gleason 7f37f16c7b Upgrade React from 18.3 to 19.2
- Upgrade react, react-dom to ^19.2.4 and @types/react, @types/react-dom to v19
- Upgrade @nostrify/react to 0.4.0 (peer deps fix for React 19)
- Upgrade vaul to 1.1.2 and react-day-picker to 9.14.0 for React 19 compatibility
- Fix useRef() calls to pass explicit initial values (required in React 19)
- Update RefObject types to include null (React 19 type change)
- Rewrite Calendar component for react-day-picker v9 classNames API
- Add npm overrides for @emoji-mart/react (only remaining React 18 holdout)
2026-03-28 00:29:37 -05:00
Chad Curtis a67d007435 Merge branch 'fix/badge-profile-and-give-badge' into 'main'
Add top badges to profile and 'Give badge' to profile menu

Closes #189 and #185

See merge request soapbox-pub/ditto!121
2026-03-28 05:19:02 +00:00
Chad Curtis a5c6645d2d Fix GiveBadgeDialog bugs and DRY up ProfileMoreMenu handlers 2026-03-28 00:16:18 -05:00
Alex Gleason cbe50a0232 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-28 00:01:19 -05:00
Alex Gleason a48ac48202 Trim Sentry bundle by tree-shaking unused replay/feedback/canvas modules
Use named imports in the dynamic import of @sentry/react so the bundler
can drop re-exported modules we never reference (replay 207K, feedback 67K,
replay-canvas 25K). Also set defaultIntegrations: undefined to prevent
Sentry from pulling those modules at runtime.

Sentry chunk: 431K → 128K (-70%).
2026-03-28 00:00:21 -05:00
Mary Kate Fain 0a7aaca6e5 Move badges from sidebar to profile bio section
Display badge preview inline in the profile bio area (after the
about text) as a horizontal row of thumbnails, matching the style
used in the profile hover card. This works on both desktop and
mobile since it's part of the main profile content.
2026-03-27 23:47:01 -05:00
Mary Kate Fain 6e41ea3b42 Add badges to profile sidebar and 'Give badge' to profile menu
- Show accepted badges in the profile right sidebar (fixes #189)
- Add 'Give badge' option to the profile 3-dot overflow menu,
  allowing users to award their created badges directly from
  a user's profile (fixes #185)
2026-03-27 23:47:01 -05:00
Chad Curtis 75ada621d9 Merge branch 'fix/font-picker-click-through' into 'main'
Fix: Prevent font picker clicks from passing through to theme selector

Closes #171

See merge request soapbox-pub/ditto!115
2026-03-28 04:44:53 +00:00
Alex Gleason 12d578ff57 Improve bundle chunking: lazy-load emoji picker, markdown, and remove runtime tailwind config
- Hardcode md breakpoint in useIsMobile and toaster to eliminate runtime
  import of tailwind.config (was pulling in tailwindcss, postcss-selector-parser,
  and plugin code ~100KB)
- Lazy-load EmojiPicker in ComposeBox (emoji-mart + data ~500KB deferred)
- Dynamic import webxdcMeta.ts (smol-toml + fflate only loaded for .xdc uploads)
- Lazy-load ArticleContent, PullRequestCard, CustomNipCard in NoteCard and
  PostDetailPage (react-markdown + unified pipeline ~147KB deferred)
- Consolidate 60+ lucide-react icon micro-chunks into a single chunk via
  manualChunks, reducing HTTP request overhead
- ReplyComposeModal chunk: 808KB -> 296KB (-63%)
- JS file count: 226 -> 181 (-45 files)
2026-03-27 23:44:20 -05:00
Chad Curtis 3486b7f503 Merge remote-tracking branch 'origin/main' into fix/font-picker-click-through
# Conflicts:
#	src/components/FontPicker.tsx
2026-03-27 23:43:46 -05:00
Chad Curtis 0aed5e0f31 Merge branch 'fix/emoji-picker-contrast' into 'main'
Fix: Improve contrast on focused emoji category text in picker

Closes #174

See merge request soapbox-pub/ditto!116
2026-03-28 04:41:26 +00:00
Alex Gleason 34c40980e3 Merge branch 'remove-dev-buttons' into 'main'
Removing dev buttons

See merge request soapbox-pub/ditto!127
2026-03-28 03:09:08 +00:00
Alex Gleason b13eb6012c Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:59:00 -05:00
Alex Gleason ecc3284a94 Improve code splitting: reduce index chunk from 1021K to 494K
- Lazy-load BlobbiCompanionLayer (~450K blobbi code off critical path)
- Split BlobbiActionsProvider into lightweight file to avoid pulling
  heavy blobbi action system into the index chunk
- Fix HomePage eagerly importing all 18 page components; use lazy()
  so only the configured homepage's chunk is loaded
- Lazy-load ReplyComposeModal in AppRouter and FloatingComposeButton
  to defer emoji-mart (~620K) until compose is opened
- Fix barrel import in App.tsx pulling BlobbiDevEditor into index;
  use direct import from EmotionDevContext instead
2026-03-27 20:56:36 -05:00
filemon b10c8ff182 Hide Blobbi dev controls on deployed environments
Add isLocalhostDev() helper that checks both import.meta.env.DEV AND
hostname (localhost/127.0.0.1/0.0.0.0). This ensures dev buttons only
appear during local development, never on deployed apps.

Dev controls now hidden in production:
- Dev Hatch/Evolve instant transition buttons
- Dev State Editor button
- Dev Emotion Tester button
2026-03-27 22:40:17 -03:00
Alex Gleason d96e222a15 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-03-27 20:40:02 -05:00
filemon ec63533108 Merge branch 'main' into remove-dev-buttons 2026-03-27 22:32:47 -03:00
Alex Gleason 6f74366dd9 Restore code splitting with React.lazy for all non-critical page routes
Convert 44 page imports from static to React.lazy() with dynamic imports.
Only HomePage, Index, and NotFound remain eagerly loaded as critical-path
pages. The existing Suspense boundary in MainLayout (with PageSkeleton
fallback) already wraps the content area, so lazy pages show a skeleton
while loading without affecting the sidebar or navigation.
2026-03-27 20:30:32 -05:00
filemon 4b5825790a Fix daily missions UI not updating after reroll/claim
Replace useLocalStorage with direct localStorage reads that re-trigger
when the 'daily-missions-updated' event fires. The previous approach
cached state internally and didn't see same-tab localStorage writes.

Now when mutations write to localStorage and dispatch the event:
- Version counter bumps
- useMemo re-reads from localStorage
- UI updates immediately without page refresh
2026-03-27 22:29:20 -03:00
Alex Gleason c257e61fa7 Add bundle analyzer (rollup-plugin-visualizer) to the build 2026-03-27 20:26:21 -05:00
filemon f7391c0e0b Rebalance daily mission rewards to match shop economy
- Increase easy mission rewards to 25-30 coins
- Increase medium mission rewards to 45-50 coins
- Increase high-effort missions (photo, multiple feeds) to 55-70 coins
- Increase medicine missions to 60-70 coins (since medicine costs coins)
- Increase Daily Champion bonus from 50 to 80 coins

Target daily economy: 130-180 coins normal day, 210-250 with bonus
2026-03-27 22:24:48 -03:00
Alex Gleason dce3d5b411 Merge branch 'feat-blobbi' into 'main'
Blobbi: add core pet system, interactions, missions, etc

See merge request soapbox-pub/ditto!104
2026-03-28 01:23:11 +00:00
Alex Gleason a2490da3b4 Add badge kinds (30009, 30008) to the Ditto tab feed 2026-03-27 20:22:02 -05:00
filemon 1aacc0073f Fix egg-compatible missions and add medicine missions for reroll
Mission pool changes:
- Add 'medicine' action type for giving medicine to Blobbi
- Add medicine_1 (30 coins) and medicine_2 (50 coins) missions
- Mark clean, sing, play_music, medicine as available for ALL stages (egg+baby+adult)
- Keep interact, feed, sleep, take_photo as baby/adult only

Egg users now have 8 valid missions:
- clean_1, clean_2
- sing_1, sing_2
- play_music_1, play_music_2
- medicine_1, medicine_2

This ensures egg-only users always have alternatives when rerolling
(need at least 4 missions for 3 daily + 1 reroll target)
2026-03-27 22:14:40 -03:00
filemon 6f2d80b99e Fix mission reroll - expand pool and add state migration
- Expand DAILY_MISSION_POOL from 7 to 15 missions with more variety
  - Multiple difficulty tiers for feed, clean, interact, sing, play_music, take_photo
  - Ensures always having alternatives when rerolling
- Fix selectReplacementMission to properly exclude only active missions
- Add state migration for rerollsRemaining in both hooks
  - Old localStorage states without rerollsRemaining now get 3 rerolls
- Improve getRerollsRemaining to handle undefined/null values
- Better error message when pool is exhausted
2026-03-27 22:10:40 -03:00
filemon a8e7901eac Add mission reroll feature for daily missions
- Add rerollsRemaining to DailyMissionsState (max 3 per day, resets daily)
- Add rerollMission() function with stage-aware replacement selection
- Replacement avoids duplicates and the mission being replaced
- Add useRerollMission hook for mutation with toast feedback
- Add reroll button (RefreshCw icon) to incomplete missions
- Show remaining rerolls count at top of mission list
- Disable reroll for completed/claimed missions
- Bonus mission still works correctly after rerolling
2026-03-27 22:01:50 -03:00
filemon d4ae9d9611 Add stage-based daily mission filtering and bonus mission reward
- Add requiredStages property to mission definitions (all current missions require baby/adult)
- Update selectDailyMissions() to filter by user's available Blobbi stages
- Show 'Hatch Your Blobbi First' message when user only has eggs
- Add 50-coin 'Daily Champion' bonus mission after completing all regular missions
- Bonus mission appears locked until all regular missions are completed
- Update useClaimMissionReward hook to support claiming bonus rewards
- Pass availableStages through modal props for proper filtering
2026-03-27 21:56:41 -03:00
filemon d82a3cffe8 Allow empty name input during Blobbi egg adoption editing
Previously, clearing the name input would immediately restore it to 'Egg',
which made for a frustrating UX when trying to fully clear and retype.

Now:
- Name input can be fully cleared while editing
- Validation error shows when name is empty
- Adopt button is disabled when name is empty/whitespace
- Only validate on submit, not on every keystroke
2026-03-27 21:43:06 -03:00
filemon 05e189b938 Fix SVG ID prefix to use full Blobbi ID instead of first 8 chars
The previous fix used instanceId.slice(0, 8) for the prefix, but since
Blobbi IDs have the format 'blobbi-{pubkeyPrefix12}-{petId10}', the first
8 characters are always 'blobbi-' for all Blobbis owned by the same user.

This caused gradient ID collisions between different Blobbis.

Now using the full sanitized instanceId as the prefix:
  b_blobbi-abc123456789-xyz1234567

This ensures each Blobbi gets truly unique SVG IDs.
2026-03-27 21:39:01 -03:00
filemon d32d0b17d0 Fix gradient ID collisions causing wrong colors in Blobbi modal list
When multiple Blobbis are rendered on the same page (like in the selector modal),
they all shared the same SVG gradient IDs (e.g., cattiBody3D, blobbiBodyGradient).
The browser only uses the first definition of each ID, so all subsequent Blobbis
would use the first one's colors instead of their own.

Fixed by:
- Adding uniquifySvgIds() function to both adult and baby SVG customizers
- Generating unique prefixes from each Blobbi's ID (first 8 characters)
- Prefixing all SVG IDs and updating all references (url(), href, xlink:href)
2026-03-27 21:28:04 -03:00
filemon 1d87315426 Adjust eyebrow position higher for owli and froggi forms 2026-03-27 21:01:17 -03:00
filemon 4db0e8870d Fix clipPath ID collision causing eyes to disappear with multiple Blobbis 2026-03-27 20:54:06 -03:00
filemon deb59b314b Fix adult eye layering by excluding EyeBase gradients from eye white detection
The isEyeWhiteElement function was incorrectly matching colored eye rim gradients
like froggiEyeBase3D (green frog eye bulge) because it matched any gradient with
'Eye' in the name. This caused the eyelid to be placed behind the eye base layer,
making only the eyelid visible.

Now the detection:
- EXCLUDES EyeBase patterns (colored eye rims)
- INCLUDES EyeWhite patterns (actual white of eye)
- INCLUDES generic Eye gradients without 'Base' (baby Blobbi, etc.)
2026-03-27 20:25:32 -03:00
filemon 4c75d4f559 Add missing form-specific color customizers for adult Blobbi SVGs
Complete the color mapping system by adding customizers for:
- breezy: body, inner, veins, arms, legs, floating leaves
- bloomi: all 6 petals with color variations, center, pollen
- cacti: body, arms (pot keeps original red)
- cloudi: body, highlights, raindrops
- crysti: body, inner (facets keep colorful nature)
- owli: body, ears, wings (beak keeps yellow/orange)

Pandi intentionally excluded as it's a panda with black/white coloring by design.
2026-03-27 20:17:26 -03:00
filemon 8bd2bca879 Add form-specific color customizers for adult Blobbi SVGs
Implement comprehensive gradient replacement for each adult form to ensure
Blobbi custom colors are properly applied to all visual elements (body, ears,
tail, arms, legs, petals, etc.) while preserving 3D shading gradients.

Forms with full color mapping: catti, droppi, flammi, froggi, leafy, mushie,
rocky, rosey, starri. Forms owli/pandi keep original colors by design.
2026-03-27 20:12:01 -03:00
filemon 97748dfd34 Remove page entry from streak triggers - only real interactions count
Streak now only updates from actual care interactions:
- Direct actions (play_music, sing)
- Inventory item use (feed, clean, treat, etc.)
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake toggle)

Page visits and app opens no longer count toward streak.
2026-03-27 17:08:27 -03:00
filemon 0d8b320f31 Implement improved Blobbi care streak system with day-based tracking
Add new streak tags to kind 31124 events:
- care_streak: Consecutive days of care (starts at 1, resets to 1 if 2+ days missed)
- care_streak_last_at: Unix timestamp of last streak update
- care_streak_last_day: Local calendar day (YYYY-MM-DD) of last update

Streak validation rules:
- Initialize to 1 on first activity
- Increment when activity occurs on the next local day
- Same-day activity does not increment (at most once per day)
- Missing 2+ days resets streak to 1

Files added:
- blobbi-streak.ts: Centralized streak calculation logic
- useBlobbiCareActivity.ts: Hook for registering care activity

Streak integration points:
- Blobbi page entry (automatic check-in)
- Direct actions (play_music, sing)
- Inventory item use
- Stage transitions (hatch, evolve)
- Rest action (sleep/wake)
- Companion item use (outside BlobbiPage)
2026-03-27 17:01:35 -03:00
filemon 6ab05471b2 Migrate Blobbonaut profile from kind 31125 to 11125 with auto-onboarding
- Change KIND_BLOBBONAUT_PROFILE constant from 31125 to 11125
- Add KIND_BLOBBONAUT_PROFILE_LEGACY (31125) for migration support
- Add BLOBBONAUT_PROFILE_KINDS array to query both kinds
- Update useBlobbonautProfile to prefer 11125 over 31125
- Add needsKindMigration flag for legacy profile detection
- Extend useBlobbonautProfileNormalization to auto-migrate legacy kinds
- Refactor onboarding to auto-create profile using kind 0 name
- Remove manual name entry step from onboarding flow
- Delete unused BlobbiProfileOnboarding component
- Update all documentation comments to reference kind 11125
2026-03-27 16:27:26 -03:00
filemon 1291a0e932 Fix: ensure t and client tags are removed from Blobbi events
The previous commit removed these tags from event building functions, but
the tag validation/repair system in blobbi-tag-schema.ts was re-adding them:

1. BLOBBI_TAG_SCHEMA had 't' marked as required:true with defaultValue:'blobbi'
2. RECOVERABLE_SYSTEM_TAGS had both 't' and 'client' with default values
3. DEPRECATED_TAG_SCHEMA did not include 't' or 'client'

Fixed by:
- Removing 't' and 'client' from BLOBBI_TAG_SCHEMA (no longer required)
- Removing 't' and 'client' from RECOVERABLE_SYSTEM_TAGS
- Adding 't' and 'client' to DEPRECATED_TAG_SCHEMA

Now the validateAndRepairBlobbiTags function will properly filter out
these tags during any republish/migration/update flow.
2026-03-27 15:58:44 -03:00
filemon a2d40c5cbf Remove Blobbi shape system and clean up t/client tags
- Remove BLOBBI_TOPIC_TAG and BLOBBI_CLIENT_TAG from event building
- Add t and client to DEPRECATED_BLOBBI_TAG_NAMES for migration cleanup
- Update validation functions to not require t tag
- Delete blobbiShapes.ts and BlobbiShapePicker.tsx entirely
- Simplify avatarShape.ts to only support emoji shapes
- Remove blobbi_shape task from useEvolveTasks
- Remove change_shape task from useHatchTasks
- Remove change_shape mission from daily-missions
- Clean up ProfileCard shape picker to only show emoji picker

The app's useNostrPublish hook already adds client tags automatically,
making the explicit client tag redundant. Old events with these tags
will have them stripped on next save.
2026-03-27 15:54:20 -03:00
filemon 2e5e6c9ad3 feat: add 'hungry' emotion for Blobbi
New emotion that conveys low energy + wanting food:
- Watery eyes (like sad) but WITHOUT blue water fill - longing, not crying
- Worried/sad eyebrows for that wanting/longing look
- Droopy mouth - less curved than sad frown, softer and more tired
- Small drool drop from corner of mouth with subtle wobble animation
- Fork & knife icon above head (subtle, 65% opacity)

Also adds new config types:
- DroolConfig: drool drop effect
- FoodIconConfig: utensils/plate icon above head
- DroopyMouthConfig: weak/tired frown with adjustable width and curve
2026-03-27 12:56:45 -03:00
filemon c1e9143483 fix: add missing pupil colors for adult Blobbi eye detection
Added #1e1b4b (dark indigo) for starri/crysti and #0891b2 (cyan) for
droppi to the PUPIL_COLORS array. Without these colors, the eye
animation system couldn't detect pupils in these adult forms, causing
eyes to not render properly (only showing eyelids).
2026-03-27 12:25:59 -03:00
filemon 774f7d2dbe Add sparkles around Blobbi and create excited variation B
Sparkles:
- Move sparkles from around eyes to around entire Blobbi body
- 11 sparkles distributed around the perimeter (top, sides, bottom)
- Subtle fade/twinkle animation with staggered timing
- Soft opacity (0.7 max) for gentle effect

Excited variations:
- Excited A (original): star eyes + big smile
- Excited B (new): star eyes + round 'O' mouth (like curious)
- Both include sparkles around the Blobbi
- Added excitedB to emotion tester panel for comparison
2026-03-27 12:16:25 -03:00
filemon de9eab1e4e Finalize emotion visual tweaks
Sleepy closed-eye lines:
- Increase curve depth (0.5x radius) to match eye curvature
- Position slightly lower (0.75x radius offset)
- Disappear immediately at 63% (as eyes start opening)

Excited star eyes:
- Reduce star scale from 1.4 to 0.9 for cuter look
- Insert stars INTO blobbi-eye groups so they track with eye movement
- Stars now follow mouse cursor like normal pupils

Excited sparkles:
- Add 4 animated sparkles around each eye
- Small 4-pointed star shapes that twinkle
- Staggered animations (0.3s delay between each)
- Positioned at orbit around the eyes
2026-03-27 12:07:27 -03:00
filemon 9b1925d6de Refine excited, sleepy, and adoring emotions
Excited eyes:
- Keep white eye circle visible behind the star
- Only hide pupils (.blobbi-eye), not entire blink group

Sleepy animation:
- Update to use new clip-path blink system
- Add SMIL animations to clip-path rects for eye closing
- Remove old scaleY CSS animation
- Eyes now close with natural eyelid-down effect

Adoring eyes:
- Add includeWaterFill option to PupilModification
- Adoring uses watery highlights but no blue fill
- Sad retains the blue watery semicircle
2026-03-27 12:02:24 -03:00
filemon a6ad1bdcdb Refine blink animation and eyelid system
Blink behavior:
- Change from scaleY to clip-path mask approach
- Eye keeps original size, visible area cropped from top to bottom
- Creates natural eyelid-closing effect revealing eyelid layer behind
- Add clipPath definitions and animate rect Y/height

Eyelid color:
- Reduce darken amount from 15% to 8%
- Subtle contrast that reads as eyelid, not shadow

Sleepy closed-eye lines:
- Lower position by 70% of eye radius
- Aligns with final closed eye position in clip-path system
2026-03-27 11:53:25 -03:00
filemon f9c8bbc4cc Add eyelid background layer and update excited emotion with star eyes
Eyelid layer:
- Add blobbi-eyelid ellipse behind each eye white
- Derive color from base body color (darkened by 15%)
- Pass baseColor to addEyeAnimation from visual components
- Ready for future blink animation integration

Excited emotion:
- Replace eyes with 5-pointed golden stars
- Add big smile (30% wider, 40% deeper curve)
- Hide normal eyes when stars are active
- Clean, readable across baby and adult variants
2026-03-27 11:44:30 -03:00
filemon 3634440c8b Update excited emotion and add adoring emotion
- excited: use normal eyes, keep animated eyebrows, use curious round mouth
- adoring: watery eyes (like sad), no eyebrows, curious round mouth
- Add adoring to emotion tester panel
2026-03-27 11:37:18 -03:00
filemon c963673a19 feat(blobbi): add dizzy, excited, mischievous emotions with animated effects
- Add dizzy emotion with rotating spiral eyes (counter-clockwise)
- Add excited emotion with watery eyes, bouncing sad-style eyebrows, and smile
- Add mischievous emotion with bouncing angry-style eyebrows and small smug smile
- Wrap eyebrows in groups to preserve rotation while CSS animates translateY
- Add new emotions to DEV emotion panel for testing
2026-03-27 02:17:49 -03:00
filemon 0602c1b59d refine(blobbi): adjust sleepy animation timing and transitions
Three targeted refinements to sleepy emotion:

1. Mouth transition simplified:
   - Now goes directly: smile → U-shaped → smile
   - Removed intermediate flat line phase
   - Smoother, more natural transition

2. Eyes fully hidden when closed:
   - Changed scaleY from 0.05 to 0 when fully closed
   - Original eye completely disappears
   - Only curved closed-eye line visible during sleep

3. Zzz appears from the beginning:
   - Starts at 0% with opacity 0
   - Fades in softly: 10% → 0.2, 20% → 0.4
   - Full opacity by 35% (during sleep)
   - Creates 'getting sleepy' feel from the start
2026-03-27 01:33:04 -03:00
filemon d8a81879b1 feat(blobbi): redesign sleepy emotion as micro-sleep animation
Complete rewrite of sleepy emotion to be a 'micro-sleep' cycle:

Eye Animation:
- Slow eye closing over ~3 seconds (not partial blinks)
- Eyes fully close with scaleY near 0
- Curved closed-eye lines appear when eyes shut (like eyelids)
- Wake-up includes quick right-left glance before settling

Mouth Animation:
- Smile → flat line → small round mouth (while falling asleep)
- Small round mouth holds during sleep
- Reverses back: round → flat → smile (while waking)
- Uses SMIL path animation for smooth morphing

Sleep State:
- Zzz text appears above head during sleep
- Zzz fades in/out and floats upward
- Sleep holds for ~1 second before waking

Full 8-second cycle timeline:
- 0-10%: Awake, normal smile
- 10-35%: Getting sleepy, eyes closing, mouth flattening
- 35-50%: Eyes closed, mouth becomes round
- 50-62%: Asleep (hold with Zzz)
- 62-75%: Waking, eyes opening
- 75-90%: Quick right-left glance
- 90-100%: Return to normal, cycle repeats
2026-03-27 01:27:46 -03:00
filemon c054bc7bc7 Merge branch 'main' into feat-blobbi 2026-03-27 01:25:27 -03:00
filemon daf35f6e41 feat(blobbi): add curious asymmetric eyebrow and sleepy animation
Curious emotion:
- Right eyebrow now raised slightly more than left for questioning look
- Added per-eye override support in EyebrowConfig (leftEyeOverride/rightEyeOverride)

Sleepy emotion:
- Implemented 3-stage tired blink animation cycle:
  1. Small blink (~25% closed)
  2. Medium blink (~55% closed)
  3. Heavy blink (~80% closed)
- Mouth animates from smile → flat → smile in sync with blinks
- Uses CSS keyframe animation for smooth, slow transitions
- 8-second cycle duration for natural tired feel
- Added SleepyAnimationConfig type for configuration
2026-03-27 01:17:05 -03:00
filemon d947a951ad feat(blobbi): add curious/surprised emotions and adjust baby eyebrows
- Add 'curious' emotion: small round mouth, soft curved eyebrows
- Add 'surprised' emotion: larger round mouth, raised curved eyebrows
- Add round mouth generation (replaces default mouth like sad/angry)
- Add curved eyebrow support via new 'curve' config option
- Move baby eyebrows slightly farther from eyes (-2px offset)
- Add variant parameter to applyEmotion for baby/adult differences
- Add both new emotions to DEV emotion panel
2026-03-27 01:00:01 -03:00
filemon 273cf1094d fix(blobbi): fix sad eye highlights and add angry body animation
- Fix invalid SVG generated by sad highlight injection regex
- Refactor water fill to insert inside blink groups (below pupil, above eye white)
- Add anger-rise body effect that animates red color from bottom to top
- Uses clipPath to constrain effect to body shape
2026-03-27 00:51:32 -03:00
filemon c907779b3c Fix sad eye highlight injection with robust parsing and debug logs
- Replace fragile regex with index-based string manipulation
- Find opening tag first, then locate closing tag by position
- Add DEV-only debug logging for eye detection and injection
- Log whether blobbi-eye groups are found and matched
- Log eye positions and water fill generation
- Should fix sad highlights not appearing in blobbi-eye groups
2026-03-27 00:35:24 -03:00
filemon 2d9ff34ded Fix sad eye tracking/blinking integration and slow down tears
- Hide original highlights by adding opacity=0 inside blobbi-eye groups
- Inject sad highlights INTO blobbi-eye groups so they track with pupil movement
- Sad highlights now move with eye tracking and participate in blinking
- Blue water fill stays as overlay (on eye white, doesn't need tracking)
- Slow tears: duration 6s with 3s pause between cycles
- Alternating tear mode: tears switch sides each cycle (no flickering)
- Only affects SAD emotion, angry/neutral unchanged
2026-03-26 20:32:18 -03:00
filemon d1a85659ba Fix sad eye visuals: reposition water shape and highlights
- Position blue water shape relative to eye white (not pupil center)
- Water now sits at bottom of eye white like pooled tears
- Reposition highlights: upper (larger) and lower (smaller) with clear separation
- Upper highlight at cy - radius*0.55, lower at cy + radius*0.35
- Only affects SAD emotion (generateSadEyeEffects), angry unchanged
2026-03-26 20:28:44 -03:00
filemon d832e6e364 Fix Blobbi sad emotion visuals and dev panel labeling
- Lower sad mouth position by adding Y offset based on curve amount
- Fix sad eye highlights: larger top-left, smaller right-side
- Change blue watery fill to proper lower 1/3 semicircle shape using path
- Fix baby eye detection to match gradient fills (url(#...Pupil...))
- Swap sad/angry eyebrow angles: sad now worried (/\), angry now aggressive (\/)
- Replace mouth safely using regex-only approach (no section slicing)
- Update dev panel to label default as 'Default' with happy emoji
2026-03-26 20:24:05 -03:00
filemon 66cfe9ee45 Add Blobbi emotion system with DEV testing panel
- Add EmotionDevContext and BlobbiEmotionPanel for dev-only emotion testing
- Create emotions.ts with configurable emotion overlays (sad, happy, angry, surprised, sleepy)
- Use deterministic tear selection (hash-based) to prevent flickering
- Add marker-based SVG detection with regex fallback for mouth/eye elements
- Update visual components to pass emotion prop through hierarchy
- Add SVG comment markers to all Blobbi base SVGs for reliable element detection
2026-03-26 19:47:36 -03:00
Alex Gleason e530e38721 fix: prevent font picker clicks from passing through to theme selector
Fixes #171
2026-03-25 18:35:25 -05:00
Alex Gleason d98ae9cdbf fix: consistent feed header ordering — title above tabs on all feed pages
Fixes #153, #155, #156, #157, #158, #159
2026-03-25 18:29:52 -05:00
Alex Gleason 5100b76ad3 fix: improve contrast on focused emoji category text in picker
Increases the opacity of emoji-mart nav button text from 0.65 to 0.85
by injecting CSS overrides into the shadow DOM. This improves readability
and meets WCAG contrast requirements for the category navigation icons.

Fixes #174
2026-03-25 18:28:45 -05:00
Alex Gleason 8328af802f fix: auto-focus search box when emoji picker opens
Fixes #162
2026-03-25 18:27:43 -05:00
The Daniel 87914291c6 ci: retrigger pipeline 2026-03-25 15:27:53 -04:00
filemon 0c7daef65e Add DEV MODE Blobbi state editor for testing
- Create BlobbiDevEditor modal component for direct state editing
- Add useBlobbiDevUpdate hook using standard update/publish flow
- Support editing: stage, state, adult form, all stats
- Support editing: experience, care streak, generation, breeding ready, visibility
- Add stat presets: Max Stats, Starving, Exhausted, Dirty, Sad, Critical Health, etc.
- Add wrench icon button to hero section (DEV only)
- Wire to existing updateBlobbiTags pipeline for consistent Nostr events
- Only renders in development mode (import.meta.env.DEV)
2026-03-25 12:42:44 -03:00
filemon 09da778d3b Add need-based Blobbi reactions when items land
- Create centralized need detection system with configurable thresholds
- Add continuous gravity for items dropped mid-air (fall to ground)
- Blobbi now glances at items it doesn't need (brief look)
- Blobbi shows interest in items it needs (longer attention)
- Add ItemLandedData interface with position info for reactions
- Create useCompanionItemReaction hook for need-based behavior
- Expose triggerAttention from useBlobbiCompanion hook
2026-03-25 12:23:13 -03:00
filemon 2ad64bbca7 Filter egg-only items from companion interaction system
Since companions can only be baby or adult (not egg), egg-only items
like Shell Repair Kit should never appear in the companion flow.

Changes:
- Update resolveItemsForAction to use centralized canUseItemForStage
- Add item-stage validation in useBlobbiItemUse mutation
- Egg-only items are now filtered at both display and use layers

The filtering is now enforced by:
1. resolveItemsForAction - items won't appear in hanging items menu
2. useBlobbiItemUse - validation prevents use even if somehow displayed
2026-03-25 11:33:05 -03:00
filemon 247b94f3b3 Fix item effect display consistency across Blobbi UIs
- Create shared ItemEffectDisplay component as single source for effect rendering
- Update BlobbiInventoryModal to show ALL effects (was truncated to 2)
- Update BlobbiShopItemRow to show ALL effects (was truncated to 3)
- Update BlobbiPurchaseDialog to use shared component
- Use canonical stat display order: hunger, happiness, energy, hygiene, health
- Deprecate formatEffectSummary in favor of ItemEffectDisplay component

The root cause was effect display truncation in the UI, not inconsistent data.
All item definitions remain in blobbi-shop-items.ts (single source of truth).
2026-03-25 11:24:36 -03:00
filemon 8078ad5609 Refactor hanging items to support multiple dropped instances
- Replace releasedItemIds (Set) with releasedCountByItemId (Map) to track
  how many instances of each item type have been released
- Generate unique instanceId for each dropped item (format: itemId-timestamp-counter)
- Hanging items now show remaining quantity (quantity - releasedCount)
- Multiple instances of the same item type can exist on the ground simultaneously
- Each dropped instance tracks independently via instanceId
- Update all callbacks and state tracking to use instanceId instead of item.id
- When item is used successfully, decrement releasedCount for that item type

This enables the desired UX where clicking a hanging item immediately shows
a new copy in the hanging slot (if quantity > 1), while the released instance
falls independently.
2026-03-25 11:07:54 -03:00
filemon 7fd70ac0d9 fix(blobbi): make item-use work reliably from companion system
Major architectural fix for Blobbi companion item-use system:

1. Created shared useBlobbiItemUse hook
   - Works standalone outside of BlobbiPage
   - Uses same real item-use logic as BlobbiPage
   - Built-in per-item cooldown tracking
   - Fetches companion/profile data on-demand when needed

2. Refactored BlobbiActionsContext
   - Now has built-in fallback using useBlobbiItemUse
   - Item use works from ANY page, not just /blobbi
   - BlobbiPage registration is optional (provides better cache access)
   - No more 'canUseItems = false' when BlobbiPage not mounted

3. Fixed retry/flood issues in HangingItems
   - Added per-item cooldown (3s on failure, 0.5s on success)
   - Implemented zone ENTRY detection (not continuous overlap)
   - Items only trigger auto-use when ENTERING the Blobbi zone
   - Items must leave zone before re-triggering
   - Multiple protection layers prevent spam

4. Fixed useBlobbonautProfile side-effect
   - Moved setBootCache from useMemo to useEffect
   - Added ref-based signature tracking to prevent loops
   - Proper cleanup and stable dependencies

Files changed:
- NEW: src/blobbi/companion/interaction/useBlobbiItemUse.ts
- src/blobbi/companion/interaction/BlobbiActionsContext.tsx
- src/blobbi/companion/interaction/HangingItems.tsx
- src/blobbi/companion/interaction/index.ts
- src/blobbi/companion/components/BlobbiCompanionLayer.tsx
- src/hooks/useBlobbonautProfile.ts
2026-03-25 06:38:37 -03:00
filemon dbdaff2ada fix(blobbi): resolve performance issues in falling-item drag-drop system
Root cause analysis and fixes:

1. Drag-to-use freeze/loop (HangingItems):
   - Problem: When dropping on Blobbi, item position was set ON Blobbi,
     triggering contact detection to also call attemptUseItem, creating a loop
   - Problem: attemptUseItem had itemsBeingUsed as a dependency, so when it
     changed (inside the callback), the callback identity changed, re-triggering
     the contact detection effect
   - Fix: Changed itemsBeingUsed from state to ref to avoid callback recreation
   - Fix: When dropping on Blobbi, reset item to ORIGINAL position before
     attempting use (prevents contact detection from firing)
   - Fix: Made attemptUseItem have no dependencies (uses refs for everything)
   - Fix: Gated all console.log calls behind import.meta.env.DEV

2. useBlobbonautProfile console flood:
   - Problem: Unconditional console.log at line 63 ran on EVERY render
   - Problem: queryFn had multiple console.logs that ran on every query
   - Fix: Removed/commented out all console.logs in the hook
   - Analysis: The hook itself was NOT causing extra renders - it was just
     exposing the render frequency with its logging

3. BlobbiActionsContext registration instability:
   - Problem: Registration used useState which triggered re-renders on every
     update, and the registration effect depended on useItem identity
   - Fix: Refactored to use refs instead of state for registration data
   - Fix: Added subscription pattern for manual notification only when
     canUseItems actually changes (major state change)
   - Fix: Consumer hook's useItem callback is now stable (reads from ref)
   - Fix: Provider context value is now stable (never changes identity)

Guards now preventing repeated item-use attempts:
- itemsBeingUsedRef.current check at start of attemptUseItem
- Contact detection skips items in itemsBeingUsedRef
- Drag-drop resets item position BEFORE calling attemptUseItem
- attemptUseItem has no dependencies that could trigger recreation
2026-03-24 22:20:04 -03:00
filemon 3e53e368a4 fix(blobbi): wire BlobbiActionsContext correctly and add drag-drop for items
Part 1: Context Wiring Fix
- Refactored BlobbiActionsContext to use registration pattern
- BlobbiActionsProvider now mounted at app level in AppRouter (wraps BlobbiCompanionLayer)
- BlobbiPage registers its item-use function via useBlobbiActionsRegistration hook
- BlobbiCompanionLayer now receives real context with canUseItems: true
- Added debug logs to confirm context state changes

Part 2: Drag-and-Drop for Released Items
- Added 'dragging' state to ReleasedItemState
- Implemented pointer event handlers for drag detection (threshold-based)
- Items can be dragged after landing on the ground
- Visual feedback: items scale up when over Blobbi, glow effect on Blobbi
- Drop-on-Blobbi triggers real item use flow
- Drop elsewhere leaves item at drop position

All three item use paths (contact, click, drag-drop) use the same
real onItemUse callback, ensuring consistent behavior and proper
kind 31124 event publishing.
2026-03-24 22:05:23 -03:00
filemon 6a7c037ea8 feat(blobbi): implement real item use system for companion falling items
- Add BlobbiActionsContext to bridge companion UI with item use functionality
- Create useCompanionItemUse hook with category-to-action mapping (food→feed, toy→play, etc.)
- Update HangingItems with async onItemUse callback and success/failure handling
- Wire BlobbiCompanionLayer to use context-provided item actions
- Provide BlobbiActionsProvider in BlobbiPage so items actually update stats
- Items only disappear after successful use, stay on screen if use fails
2026-03-24 21:57:56 -03:00
filemon 2414441efa Merge branch 'main' into feat-blobbi 2026-03-24 21:32:47 -03:00
filemon 14deb86a7a Fix stuck rescue handoff: eliminate visual flash and improve gravity
Visual flash fix:
- Pass wasResolvedFromStuck flag through to BlobbiCompanion
- When entry was resolved from stuck and phase is 'complete', skip entry
  animation position and use motion.position directly
- This prevents the one-frame flash where 'complete' phase returns
  groundPosition before acknowledgeCompletion() runs

Gravity fix:
- Increase gravity from 800 to 3500 px/s² to match entry animation feel
- Previous value caused slow floaty descent after drag release
- New value creates responsive, natural-feeling fall that matches the
  scripted entry fall animation

The handoff now works cleanly:
1. User drags Blobbi while stuck_permanent
2. User releases → isDragging=false, motion.position=drag release point
3. resolvePermanentStuck() sets wasResolvedFromStuck=true, phase='complete'
4. BlobbiCompanion sees wasResolvedFromStuck+complete → uses motion.position
5. Physics system applies gravity from the exact release position
6. acknowledgeCompletion() runs → phase='idle' → normal motion continues
2026-03-24 21:24:38 -03:00
filemon 572c3b082e Fix stuck rescue handoff: preserve drag release position instead of snapping to groundPosition
- Add wasResolvedFromStuck flag to track whether entry completed via
  stuck rescue vs natural animation completion
- Skip setPosition(groundPosition) when entry was resolved from stuck
  rescue, since motion.position already has the correct drag release
  position from the user's drag interaction
- Motion system now continues naturally from the drag release position,
  handling gravity/falling as expected
2026-03-24 21:15:06 -03:00
filemon d2b466df93 Fix stuck_permanent visibility: increase chance to 40%, add wiggle animation, and prevent auto-resolve
- Increase trulyStuckChance from 20% to 40% for more visible stuck behavior
- Add wiggle/struggle animation when Blobbi is truly stuck at ceiling
- Fix bug where stuck_permanent would auto-resolve after 50ms because
  isDragging starts as false - now tracks whether user has actually
  started dragging before allowing resolve on release
2026-03-24 21:12:17 -03:00
filemon 3f11465a7e Fix shadow to be a floor shadow that only appears near ground
Ground Proximity Detection:
- BlobbiCompanion now calculates distanceFromGround from actual Y position
- isOnGround = not entering, not dragging, and within 5px of ground position
- Both values passed to BlobbiCompanionVisual as new props

Shadow Visibility Rules:
- Shadow only shows when isOnGround is true
- Shadow hidden during: dragging, entry animations (fall/rise), falling
- Shadow fades smoothly over SHADOW_FADE_DISTANCE (30px)
- Additional subtle fade during float animation for breathing effect

Shadow Visual Changes:
- Position: bottom -12px → -20px (farther from body, feels like floor)
- Width: 55% → 50% of size (slightly narrower)
- Height: 10% → 8% of size (thinner, more subtle)
- Blur: 3px → 4px (softer edge)
- Max opacity: 0.4 → 0.35 (more subtle)
- Added CSS opacity transition for smooth fade in/out

States Where Shadow Is Hidden:
- Being dragged (isDragging)
- Fall entry animation (isEntering, entryType='fall')
- Rise entry animation (isEntering, entryType='rise')
- Any state where distanceFromGround >= 30px
- Any off-ground position (y < groundPosition.y - 5)

States Where Shadow Is Visible:
- Idle on ground
- Walking on ground
- Floating (with subtle fade based on float offset)
2026-03-24 20:47:03 -03:00
filemon ece9a37af4 Increase Blobbi size 35% and move shadow farther away
Size Changes:
- Companion size: 80px → 108px (+35%)
- Changed in DEFAULT_COMPANION_CONFIG.size (central config)
- All derived calculations automatically use new size:
  - Entry animations
  - Drag behavior
  - Ground positioning
  - Movement bounds
  - Gaze calculations

Padding Adjustments:
- Left padding: 80px → 100px (more room for larger Blobbi)
- Right padding: 20px → 24px
- Bottom padding: 20px → 24px

Shadow Improvements:
- Position: bottom -4px → -12px (3x farther from body)
- Width: 60% → 55% of size (slightly narrower)
- Height: 12% → 10% of size (slightly thinner)
- Blur: 2px → 3px (softer edge)
- Gradient adjusted for cleaner fade

Action Menu Adjustment:
- Radius: 70px → 85px (maintains visual spacing from larger Blobbi)

HangingItems Default:
- Updated companionSize default: 80 → 108 to match config

All systems continue to work correctly:
- Entry/exit animations scale with size
- Drag centering uses config.size
- Contact detection uses passed companionSize
- Action menu follows rendered position
2026-03-24 20:41:17 -03:00
filemon 7f4cf8bdcd Fix first-click animation bug, reduce hanging/released item sizes
Animation Bug Fix:
- Root cause: useEffect with releasedItems dependency only ran AFTER
  state update, but animation check happened once at effect start
- Fix: Use refs to track animation state and latest releasedItems
- runAnimationLoop() is now a stable callback that reads from refs
- handleItemClick calls runAnimationLoop via setTimeout(0) to ensure
  state update is processed first
- isAnimatingRef prevents duplicate animation loops
- Animation now starts immediately on first item click

Size Reductions (Hanging Items):
- Circle size: 72px → 56px
- Emoji size: 2.25rem → 1.75rem
- Item spacing: 100px → 80px
- Line length: 120px → 100px
- Badge size: 24px → 20px

Size Reductions (Released/Landed Items):
- Falling emoji: 2.5rem → 1.875rem
- Landed hitbox: 48px → 40px
- Contact radius: 60px → 50px
- Fall duration: 700ms → 600ms

Preserved Behavior:
- Hanging items use line + circle
- Only emoji falls after release (no circle)
- Continuous object from falling to landed
- Landed items remain on ground
- Contact detection still removes items
2026-03-24 20:35:12 -03:00
filemon d6538aac50 Continuous item lifecycle: emoji-only fall, no respawn, Blobbi contact detection
Falling Visual - Only Emoji Falls:
- When clicked, the hanging circle/container disappears immediately
- Only the emoji itself falls (no enclosing circle, no badge)
- ReleasedItem component renders just the emoji with drop shadow
- Slightly larger emoji size for falling/landed state (2.5rem)

Continuous Object Lifecycle:
- Single ReleasedItemData tracks item through entire lifecycle
- States: hanging → falling → landed (same object throughout)
- Position animated via requestAnimationFrame (not CSS animation)
- No disappear-and-respawn: emoji smoothly transitions from fall to ground
- Fall uses eased animation: accelerates then slows near ground

Contact Detection with Blobbi:
- Receives companionPosition and companionSize from parent
- Checks distance between Blobbi center and each landed item
- Contact threshold: companionSize/2 + 60px radius
- On contact: item removed, onItemCollected callback fired
- Works both ways: Blobbi walks into item OR item lands near Blobbi
- Manual pickup also supported (clicking landed item)

State Model:
- releasedItemIds: Set<string> - tracks which items left hanging state
- releasedItems: Map<string, ReleasedItemData> - full lifecycle data
- ReleasedItemData contains: item, state, x, y, startY, targetY, fallStartTime

Future-Ready Structure:
- onItemCollected callback ready for effects/reactions
- Position data available for drag implementation
- State model supports attraction behavior
- Clean separation: hanging container vs released items
2026-03-24 20:25:25 -03:00
filemon 3a47fccf16 Improve hanging items: larger size, slide animations, landed items persist
Size Improvements:
- Circle size: 52px → 72px (38% larger)
- Emoji size: text-2xl → text-4xl (2.25rem)
- Item spacing: 80px → 100px (center to center)
- Line length: 100px → 120px
- Badge size: 20px → 24px

Open/Close Slide Animation:
- Container states: hidden → opening → open → closing → hidden
- Items descend from above when opening (slide down animation)
- Items ascend when closing (slide up animation)
- 350ms slide animation duration
- Items no longer abruptly appear/disappear

Landed Items System:
- Released items fall all the way to the ground (calculated from viewport)
- Items remain visible on ground after landing
- Landed items render as separate LandedItem components
- Landed items persist even after menu closes
- Landed items are clickable (placeholder for future pickup)

State Model:
- ContainerState: hidden | opening | open | closing
- ItemState: hanging | falling | landed
- ItemStateData tracks: id, state, fallStartX, landedY
- landedItems Map persists item positions for ground rendering

Future-Ready Structure:
- onPickup callback ready for landed items
- Separate LandedItem component for ground items
- State model supports drag, attraction, consumption
- CSS variables for dynamic fall distance
2026-03-24 20:16:26 -03:00
filemon c659eaeead Replace item bubbles with hanging items, fix action menu tracking
Action Menu Fixes:
- Remove useMemo for position calculations to avoid stale values
- Calculate button positions directly each render
- Menu now follows Blobbi continuously during all states
  (idle, walking, floating, dragging, settling)

Hanging Items System:
- New HangingItems component replaces CompanionItemBubbles
- Items appear as circles hanging from vertical lines at top of screen
- Wider horizontal spacing (80px between items)
- Playful, spatial presentation instead of modal-like container

Click-to-Fall Animation:
- Clicking an item releases it from the hanger
- Line and quantity badge disappear instantly
- Item falls with rotation animation (800ms)
- Structured for future extension (drag, attraction, reactions)

Pointer Events:
- Container uses pointer-events-none
- Individual items use pointer-events-auto
- All items are now properly clickable

Removed:
- CompanionItemBubbles component (deleted)
- Modal-like container presentation
- Close button (no longer needed)
2026-03-24 20:07:16 -03:00
filemon 37c7a37bdf Fix companion menu positioning and pointer-events
- Expose rendered position from BlobbiCompanion via onPositionUpdate callback
- Track actual visual position in BlobbiCompanionLayer (includes entry animation + float offset)
- Pass rendered position to CompanionActionMenu instead of logical motion.position
- Add pointer-events-auto to action menu backdrop, buttons, and item bubbles
- Fix pointer events hierarchy (parent layer has pointer-events-none)
- Fix unused variable lint errors (entryProgress, isPermanentlyStuck, etc.)
2026-03-24 19:56:34 -03:00
filemon d3f23544cc Add companion action menu and item bubbles interaction layer
Implement the first interaction layer for the Blobbi companion:

Click vs Drag detection:
- Created useClickDetection hook to distinguish clicks from drags
- Movement threshold: 5px (beyond = drag)
- Time threshold: 300ms (beyond = not a click)
- Updated BlobbiCompanion pointer/touch handlers to use detection

Action Menu (CompanionActionMenu):
- Radial/arc layout centered above Blobbi
- 5 actions: feed, play, medicine, clean, sleep
- Stage-aware: eggs only see medicine and clean
- Smooth fade-in + zoom animation with stagger
- Click outside closes menu
- Route change closes menu

Item Bubbles (CompanionItemBubbles):
- Horizontal row of item bubbles near top of screen
- Shows emoji + quantity badge for each item
- Resolves real inventory items for selected action
- Empty state message when no items available
- Staggered appearance animation

Architecture:
- /interaction/types.ts - Type definitions and config
- /interaction/useCompanionActionMenu.ts - Menu state hook
- /interaction/useClickDetection.ts - Click/drag detection
- /interaction/CompanionActionMenu.tsx - Radial menu component
- /interaction/CompanionItemBubbles.tsx - Item display component
- /interaction/index.ts - Module exports

Action to item category mapping:
- feed -> food items
- play -> toy items
- medicine -> medicine items
- clean -> hygiene items
- sleep -> (no items, direct action)

Future-ready for:
- Item falling animation
- Drag item to Blobbi
- Blobbi walking toward items
- Item consumption logic
- Per-item Blobbi reactions
2026-03-24 19:44:48 -03:00
filemon 9d5ea22806 Upgrade typing attention to caret-aware targeting with 4s timeout
Caret tracking implementation (priority order):
1. contenteditable: window.getSelection() + Range.getBoundingClientRect()
2. input/textarea: selectionStart + mirrored text measurement
3. Fallback: right-side typing region (where new text appears)
4. Last resort: field center

Changes from field-center version:
- Added getContentEditableCaretPosition() using Selection API
- Added getInputCaretPosition() with text width measurement via temp span
- Added getRightTypingRegion() as smart fallback (better than center)
- computeCaretPosition() tries each method in priority order

Event handling (event-driven, no polling):
- keydown: detect typing, update caret after DOM updates via rAF
- input: catch paste, autocomplete, non-keydown text changes
- selectionchange: update when caret moves via arrow keys or click
- focusin/focusout: track element changes, clean up on blur

Timing:
- Increased idle timeout from 2s to 4s for more stable observation
- Timeout resets on any typing event

Priority:
- Typing attention now uses 'high' priority
- Overrides generic modal attention while typing
- Keeps Blobbi focused on caret, not just modal center

Handles edge cases:
- Focus leaving field clears typing attention
- Modal close clears via overlay detection
- Switching fields updates target to new field's caret
- Graceful fallback when exact caret rect unavailable
- Caret position clamped to element bounds
2026-03-24 12:32:08 -03:00
filemon 1388d0e514 Add typing attention behavior for Blobbi in modal text fields
When user types in a text input inside a modal/dialog, Blobbi now:
- Detects focus on text inputs (input, textarea, contenteditable, role=textbox)
- Only activates when the field is inside an overlay (modal, dialog, sheet, drawer)
- Locks attention to the focused field's center while typing continues
- Releases attention after 2s idle timeout (no typing)
- Properly cleans up when focus changes or modal closes

Implementation:
- New useTypingAttention hook handles focus/blur/keydown events
- Integrated into useBlobbiAttention with priority below 'high' but above 'low'
- Typing attention overrides random gaze and mouse-follow during active typing
- Lightweight: uses event listeners, not polling; targets field center, not caret

Config:
- Added typingIdleTimeout (2000ms) to attention config

Edge cases handled:
- Focus moving between text fields updates attention target
- Focus leaving text inputs clears typing attention
- Modal close clears typing attention via overlay detection
- Only typing keys (chars, backspace, delete, enter) reset idle timer
2026-03-24 12:24:20 -03:00
filemon e3412fac46 Merge branch 'main' into feat-blobbi 2026-03-24 12:20:40 -03:00
filemon e43c0b1e2e Fix reactive companion visibility and add BlobbiPage duplicate prevention
Root cause: useBlobbiCompanionData had a separate query key (['companion-profile'])
from useBlobbonautProfile (['blobbonaut-profile']). When companion was
selected/removed via BlobbiPage, only the main profile query was invalidated,
leaving the companion layer with stale data.

Fix:
- Rewrote useBlobbiCompanionData to use useBlobbonautProfile instead of
  duplicating the profile query
- Now shares the same query cache, so profile updates (including currentCompanion
  changes) immediately propagate to the companion layer
- Added explicit null return when currentCompanionD is undefined for reactive removal

BlobbiPage duplicate prevention:
- Added check for active floating companion (isActiveFloatingCompanion)
- When the displayed Blobbi is the same as the floating companion, show a
  friendly message: '{name} is out exploring right now.' with Footprints icon
- Prevents seeing two identical Blobbis (one floating, one in-page)

This makes companion selection/removal/replacement fully reactive without
requiring page refresh.
2026-03-24 12:19:00 -03:00
filemon b13a5ae1ae Add reactive companion visibility with companionId tracking
- Track companionId in useBlobbiEntryAnimation to detect companion changes
- When companion is selected/changed, trigger new entry animation immediately
- Random entry direction (fall or rise) for new companion appearances
- No transition delay for companion changes (unlike route changes)
- Companion removal hides Blobbi via existing isVisible logic
2026-03-24 12:09:12 -03:00
filemon 8c52848212 Fix intermittent snap-to-ground bug in FALL entry animation
Root cause:
When entry animation completed, there was a race condition:
1. entryState.phase becomes 'complete'
2. Effect calls completeEntry() which sets isEntering = false
3. Component checks 'if (isEntering)' and switches to motion.position
4. But motion.position wasn't synced to groundPosition yet
5. Result: Blobbi snaps to old position instead of falling smoothly

The fix uses a two-phase handoff:

1. Component now checks 'isEntering || entryState.phase !== idle'
   - Keeps using entry animation position during 'complete' phase
   - The 'complete' phase returns groundPosition, so rendering is correct

2. Added acknowledgeCompletion() function
   - Called after position is synced AND rendered (via requestAnimationFrame)
   - Resets phase to 'idle' to allow normal motion to take over
   - Ensures no frame shows wrong position

3. Position sync effect now:
   - Detects phase === 'complete' (not just !isEntering)
   - Syncs position to groundPosition
   - Waits one frame with requestAnimationFrame
   - Then calls acknowledgeCompletion() to handoff to motion

Files changed:
- useBlobbiEntryAnimation.ts: Add acknowledgeCompletion()
- useBlobbiCompanion.ts: Use acknowledgeCompletion after sync
- BlobbiCompanion.tsx: Check phase !== 'idle' for entry position
2026-03-24 12:05:28 -03:00
filemon 053007e7ea Fix route change: hide Blobbi immediately, delay only new entry
Before: Blobbi stayed visible during 1s delay, then disappeared and restarted
After: Blobbi disappears immediately, 1s clean delay, then new entry starts

Changes:
- Add isHiddenForTransition state to entry animation hook
- Set true immediately on route change (before delay)
- Clear when new entry actually starts
- useBlobbiCompanion now uses shouldBeVisible = isVisible && !isHiddenForTransition

Flow:
1. Route changes
2. Blobbi hidden immediately (isHiddenForTransition = true)
3. Wait 1 second (screen is clean)
4. Start new entry (isHiddenForTransition = false, entry begins)
2026-03-24 11:57:08 -03:00
filemon 9889cb07d4 Simplify FALL entry to 2 vertical pulls, add rare permanent stuck behavior
FALL entry changes:
- Simplified to 2 pull attempts (was wiggle-based)
- Each pull: quick down, slower up (purely vertical, no diagonal)
- Normal flow (~80%): stuck -> pull_1 -> pause_1 -> pull_2 -> pause_2 -> fall -> land
- Rare flow (~20%): stuck -> pull_1 -> pause_1 -> pull_2 -> stuck_permanent

Permanent stuck behavior:
- 20% chance on FALL entry (configurable via trulyStuckChance)
- Blobbi hangs at top, won't fall automatically
- User must drag and release to rescue
- Resolves when drag ends (after any movement away from stuck position)

Route change handling:
- Cancels current entry immediately (no continuation)
- Waits 1 second after new page appears
- Then restarts entry animation for the new route

Timing (total ~1470ms for normal fall):
- stuck: 200ms (15% visible)
- pull_1: 280ms (10% drop)
- pause_1: 140ms
- pull_2: 300ms (14% drop)
- pause_2: 100ms
- fall: 450ms
- land: 200ms

Files changed:
- companion.types.ts: New phases (pulling_1/2, pause_1/2, stuck_permanent), isTrulyStuck flag
- companionConfig.ts: New timing config for 2-pull system
- animation.ts: Simplified vertical pull calculations
- useBlobbiEntryAnimation.ts: New state machine with 20% stuck chance
- useBlobbiCompanion.ts: Pass isDragging to entry hook
- BlobbiCompanion.tsx: Updated config mapping
2026-03-24 11:46:52 -03:00
filemon 142b144318 Add pause phase to FALL entry, refine butt wiggle motion
- Add 'pause' phase (180ms) between tugging and wiggling
  Creates readable 'hmm... still stuck' beat before wiggle

- Refine wiggle to feel like lower/back part:
  - Side-to-side primary motion (not diagonal whole-body)
  - Small downward pull synced with each wiggle (tugging loose)
  - Minimal rotation (2°) - just organic, not full-body tilt
  - ~1.25 cycles instead of 1.5 (brief and playful)

- Adjusted timing:
  - wiggleIntensity: 5 → 4px
  - wiggleRotation: 3 → 2°
  - wiggleDuration: 350 → 320ms

New sequence with clear beats:
  stuck (250ms) → tugging (300ms) → pause (180ms) →
  wiggling (320ms) → falling (500ms) → landing (200ms)

Total: ~1750ms
2026-03-24 11:28:53 -03:00
filemon a47e53ff2d Refine FALL entry: add tugging phase, reduce visible amount, subtler wiggle
- Add 'tugging' phase: tries to fall but gets stuck (down-up motion)
- Reduce stuckVisibleAmount from 25% to 15% (tiny butt peek)
- Make wiggle subtler: 5px intensity, 3° rotation (was 8px, 6°)
- Wiggle now diagonal movement, not just horizontal shaking
- New sequence: stuck -> tugging -> wiggling -> falling -> landing

Timing: 250ms stuck, 300ms tug, 350ms wiggle, 500ms fall, 200ms land
2026-03-24 11:23:41 -03:00
Sergey B. 25bfe4f0fa fix: query case variants for tag feed parity with search 2026-03-24 17:13:33 +03:00
filemon 8b88cd3cf6 Add stuck and wiggling phases to Blobbi fall entry animation
- Add 'stuck' phase where Blobbi's butt hangs from top edge (25% visible)
- Add 'wiggling' phase with left-right wiggle (±8px, ±6° rotation) to get loose
- Update entry state machine to handle new phases: stuck -> wiggling -> falling -> landing
- Add config values for stuckDuration (300ms) and wiggleDuration (400ms)
- Update animation utils with wiggle offset calculations
2026-03-24 11:12:33 -03:00
filemon 748bf43847 Replace sidebar entry with vertical entry based on navigation direction
- Add vertical entry system: fall from top when navigating DOWN sidebar,
  rise from bottom with inspection when navigating UP sidebar
- Add sidebarNavigation.ts utility to map routes to sidebar order
- Remove old sidebar-based entry (clipping, peeking from left)
- Fix motion sync to use groundPosition (center) instead of old restingPosition
- Entry now ends at center of screen, no teleport on completion
2026-03-24 10:58:12 -03:00
filemon 586c536c46 up animation 2026-03-24 10:16:03 -03:00
filemon 4a84a782db Add tab attention, post-route attention, and improve upward eye movement
Tab switch attention:
- Added TAB_SELECTORS for Radix UI tabs with data-state='active'
- Tab changes trigger brief glance (1.2s) instead of full attention (3s)
- Uses shorter glanceCooldown (0.8s) to allow noticing multiple tabs
- Low priority so overlays can still interrupt
- Skipped tooltips entirely (too noisy)

Post-route attention:
- After entry animation completes, Blobbi looks at main content
- findMainContentPosition() tries common selectors: main, [role=main], .main-content, article
- Falls back to center-top of viewport if no main content found
- Uses postRouteDuration (2.5s) for how long to look
- Small postRouteDelay (200ms) before triggering
- Low priority so modals can immediately interrupt

Improved upward pupil movement:
- Asymmetric vertical scaling in BlobbiBabyVisual and BlobbiAdultVisual
- Looking up: full range (4px/4.5px for baby/adult)
- Looking down: reduced range (2.4px/2.7px, 0.6x) to avoid droopy look
- Updated calculateEyeOffset() with asymmetric maxDistanceY:
  - 350px for looking up (easier to reach full upward gaze)
  - 500px for looking down (normal distance)
- Updated generateRandomScreenGaze() to favor upward looks (-0.6 to +0.5)

New config options:
- attention.glanceDuration: 1200ms for brief tab glances
- attention.glanceCooldown: 800ms between glances
- attention.postRouteDuration: 2500ms for post-route attention
- attention.postRouteDelay: 200ms delay before post-route attention

Files changed:
- companion.types.ts: New config options
- companionConfig.ts: New timing values
- companionMachine.ts: Asymmetric calculateEyeOffset
- useBlobbiAttention.ts: Tab detection, overlay/tab separation
- useBlobbiCompanion.ts: Post-route attention trigger
- useBlobbiCompanionGaze.ts: Improved random gaze range
- BlobbiBabyVisual.tsx: Asymmetric vertical eye movement
- BlobbiAdultVisual.tsx: Asymmetric vertical eye movement
2026-03-24 07:59:40 -03:00
filemon e2dbc0e1cf Improve Blobbi companion behavior: calmer, more observant, reactive to UI
Behavioral rebalancing:
- Reduced walk chance from 75% to 30% for much calmer behavior
- Increased idle time from 2-6s to 4-10s
- Increased random gaze interval from 0.8-2.5s to 1.5-4s for more deliberate observation
- Mouse follow now more frequent (35% vs 25%), longer duration (2.5s vs 1.5s)
- Observation look duration increased from 2-4s to 3-6s

New attention system for UI changes:
- Added useBlobbiAttention hook that watches for new UI elements
- Uses MutationObserver to detect modals, dialogs, sheets, popovers appearing
- Supports Radix UI, Vaul drawer, and common dialog patterns
- New 'attending' state has highest priority, interrupts walking
- Blobbi stops and looks at new UI element for ~3 seconds
- Priority system (low/normal/high) prevents spamming
- Cooldown prevents excessive reactions (1.5s minimum between events)

Architecture for future extensibility:
- AttentionTarget type with id, position, duration, priority, source
- AttentionPriority type for behavior hierarchy
- Exported via module index for external use
- Easy to add video/audio/notification attention types later

Files changed:
- companion.types.ts: Added 'attending' state, 'attend-ui' gaze mode, AttentionTarget type
- companionConfig.ts: Rebalanced all timing values, added attention config
- companionMachine.ts: Reduced walk chance, handle attending in motion
- useBlobbiAttention.ts: New hook for UI attention detection
- useBlobbiCompanionState.ts: Handle attending state, save/restore behavior
- useBlobbiCompanionGaze.ts: Handle attend-ui gaze mode with fast snap
- useBlobbiCompanion.ts: Wire attention system through
- index.ts: Export new hook and types
2026-03-24 07:44:25 -03:00
filemon 2b434de40b Fix Blobbi companion eye behavior system
- Fix competing eye systems: add disableTracking option to useBlobbiEyes
  so external systems can control eye position while keeping blinking
- Add externalEyeOffset prop to BlobbiBabyVisual and BlobbiAdultVisual
  for direct companion control of eye position
- Increase pupil movement visibility (4px for baby, 4.5px for adult)
- Increase movement direction gaze offset (0.85 instead of 0.7)
- Add observation target behavior: Blobbi picks a random screen position,
  walks toward it, then looks at it for 2-4 seconds
- Add new 'observe-target' gaze mode and 'watching' state
- Wire eyeOffset through companion pipeline to visual components
- Clear separation of concerns: useBlobbiEyes handles blinking,
  companion system handles gaze direction
2026-03-24 07:15:19 -03:00
Sergey B. a51c174021 fix: allowing zap Primal users without error 2026-03-24 11:51:02 +03:00
Sergey B. c1373174ca feat: NIP-11 relay information on network settings page 2026-03-24 10:43:25 +03:00
filemon c3f0ecf7d5 Improve companion eye gaze behavior
- When moving: Eyes look in direction Blobbi is going (left/right)
- When idle: Eyes look around randomly, observing the screen
  - Wider gaze range for more noticeable movement
  - Faster gaze changes (0.8-2.5s) feel more alive
- Mouse focus: Brief glances at cursor
  - 25% chance every 2-4 seconds (after 6s cooldown)
  - Only lasts 1.5s, then returns to normal
  - Never gets stuck on mouse
- Smooth transitions between all gaze targets
  - Mouse follow: responsive (0.15 factor)
  - Forward: moderate (0.1 factor)
  - Random: gentle (0.06 factor)
- Cleaner state management using refs to avoid unnecessary re-renders
2026-03-23 22:52:29 -03:00
filemon 2f2fdb1809 Improve companion: restore organic movement, fix eye gaze, fix adult form rendering
- Restore charming float/sway animation with layered sine waves
  - Walking: lively bouncy motion with playful tilt
  - Idle: dreamy calm floating like gentle breathing
- Fix eye gaze behavior:
  - Eyes now move randomly when idle (not stuck)
  - Occasional brief mouse following that properly times out
  - Look in movement direction when walking
- Fix adult form rendering:
  - Pass adultType and seed to visual component
  - Adults now render their actual form instead of always catti
- Keep ground contact fixes:
  - SVG alignment via translateY compensation
  - SVG fills container with width/height 100%
- Add debug mode infrastructure (disabled by default)
2026-03-23 22:48:06 -03:00
filemon 95a123532b Fix Blobbi ground contact - compensate for SVG padding in ground calculation
Root cause: The Blobbi SVG has ~12% empty space at the bottom of its
viewBox (body ends at Y=88 in a 0-100 viewBox). This caused the visual
body to appear elevated above the ground.

Fix: Added SVG padding compensation in the ground position calculation
rather than trying to hack it in the visual component.

Changes:
- calculateGroundY(): Add svgBottomPadding (12% of size) to push
  container down so Blobbi's actual body touches ground
- calculateMovementBounds(): Same adjustment for maxY bound
- Removed visual margin hacks (marginBottom, items-end) that were
  trying to compensate in the wrong place

The ground Y calculation now accounts for the SVG's internal padding,
so the container is positioned lower and Blobbi's body correctly
touches the ground level.
2026-03-23 22:26:57 -03:00
filemon 2cc7c7bcaf Fix Blobbi ground contact by compensating for SVG padding
The SVG viewBox has empty space at the bottom (~12% padding).
This was causing Blobbi to appear floating above the ground.

Fix:
- Changed inner container to use 'items-end' for bottom alignment
- Added negative marginBottom (-10% of size) to pull Blobbi down
- This compensates for the SVG's internal bottom padding

Shadow restored:
- Back to bottom: -4 (was 0)
- Size: 60% width, 12% height
- Blur: 2px
- Better visual separation from Blobbi

Result: Blobbi now visually sits on the ground with proper
shadow placement underneath.
2026-03-23 22:20:16 -03:00
filemon b314b98dd6 Fix Blobbi ground contact - now properly touches ground
Float animation fix:
- Changed from abs(sin) to (1-cos)/2 wave formula
- This creates a 0-to-1 range that regularly returns to zero (ground)
- Y offset now goes from 0 (ground contact) to -3/-4.5 (slight lift)
- Blobbi settles back to ground between float cycles

Walking animation:
- Faster cycle (~0.5s) for rhythmic bobbing
- Range: 0 to -4.5px lift
- Reduced sway (1.5px) and rotation (1.5°)

Idle animation:
- Slower cycle (~2.5s) for calm breathing
- Range: 0 to -3px lift
- Subtle sway (0.8px) and rotation (0.8°)

Shadow adjustments:
- Moved to bottom: 0 (right at ground level)
- Smaller size (55% width, 10% height) for subtler effect
- Less blur (1px) for sharper ground contact
- Stronger base opacity (0.4) that fades as Blobbi lifts
- Scale shrinks more noticeably when lifted
2026-03-23 22:13:40 -03:00
filemon 5ec79e9612 Fix Blobbi ground alignment and shadow anchoring
- Float animation now only moves Blobbi upward from ground level
  - Uses abs(sin) so Y offset is always negative (up) or zero
  - Base position = on the ground, animation lifts slightly above
- Shadow stays anchored to ground while Blobbi floats above it
  - Shadow doesn't move with float offset
  - Shadow scales/fades based on float height for depth illusion
- Reduced horizontal sway and rotation for subtler effect
  - Walking: 2px sway, 2° rotation (was 3px, 3°)
  - Idle: 1px sway, 1° rotation (was 1.5px, 1.5°)
2026-03-23 22:10:00 -03:00
filemon 9e89972008 Improve companion movement and visual polish
- Simplify entry animation to smooth walking emergence (no stuck/squeeze)
- Add forced initial walk after entry - Blobbi walks right immediately
- Improve walking behavior - 75% walk chance, shorter idle periods
- Remove visual flip when changing direction - Blobbi always faces same way
- Add soft floating/swaying animation with different speeds for walk vs idle
  - Walking: faster rhythmic bobbing (~0.8s cycle)
  - Idle: slower calm breathing (~3s cycle)
- Add soft shadow underneath for depth/floating effect
  - Stronger opacity (0.35), blur, and gradient for better visibility
  - Shadow reacts to float height
- Keep clipping behavior for sidebar emergence on desktop
- Mobile uses simple slide-in from left edge
2026-03-23 22:06:45 -03:00
filemon 05864d001a Improve companion entry animation - emerge from content area with playful squeeze effect
Entry position changes:
- Add layout config with sidebarWidth (300px) and maxContentWidth (1200px)
- Calculate main content left edge accounting for centered layout
- Entry now starts at left edge of main content area, not viewport edge
- Resting position is inside the content area with proper padding

Playful entry animation (2.2 seconds total):
- Phase 1 (0-25%): Emerge diagonally with slight forward lean and squish
- Phase 2 (25-40%): Get 'stuck' halfway with wobble effect
- Phase 3 (40-70%): Tug motions - 3 cycles of forward/back pulls, each stronger
- Phase 4 (70-100%): Break free and walk smoothly to final position

Visual effects during entry:
- Rotation (lean forward/back during tugging)
- ScaleX/ScaleY (squish/stretch for squeeze effect)
- Transform origin at center bottom for natural pivoting

The animation feels like Blobbi is squeezing out from the previous page
into the current one, getting briefly stuck, then breaking free.
2026-03-23 20:56:10 -03:00
filemon f9fc81ce71 Fix companion entry animation - no teleport, spawn from behind sidebar
- Change entry position to start behind the sidebar (padding.left/2 - size)
  instead of off-screen left edge, so companion emerges from sidebar
- Add setPosition function to motion hook for syncing position
- Sync motion position to restingPosition when entry animation completes
  to prevent teleport between animated and physics-controlled movement
- Entry is now continuous: emerges from sidebar -> slides to resting position
2026-03-23 20:48:43 -03:00
filemon 66a23cc99b Implement Blobbi companion system with modular architecture
Add a complete companion module under blobbi/companion with:

Core architecture:
- types/companion.types.ts - Type definitions for companion state, motion, gaze
- core/companionConfig.ts - Configuration constants and helpers
- core/companionMachine.ts - State machine for behavior transitions

Hooks:
- useBlobbiCompanionData - Fetches current_companion from kind 31125 profile
- useBlobbiCompanionState - Manages idle/walking/watching behavior
- useBlobbiCompanionMotion - Handles physics, walking, gravity, drag
- useBlobbiCompanionGaze - Eye movement, random observation, mouse following
- useBlobbiCompanion - Main hook combining all systems

Components:
- BlobbiCompanionVisual - Renders baby/adult with external eye control
- BlobbiCompanion - Interactive component with drag support
- BlobbiCompanionLayer - Global overlay layer

Utils:
- movement.ts - Position calculations, bounds, interpolation
- animation.ts - Bob, bounce, easing functions

Features:
- Fetches companion from user's Blobbonaut profile on app load
- Entry animation from behind left sidebar on route changes
- Autonomous walking with energy-based speed
- Random gaze observation when idle
- Mouse cursor following at random intervals
- Drag and gravity physics
- Bottom viewport roaming area
2026-03-23 20:19:31 -03:00
filemon aa2d724a13 Merge branch 'main' into feat-blobbi 2026-03-23 20:10:15 -03:00
filemon 67f840c0ec Add stage validation for companion - only baby/adult allowed
- Add canBeCompanion check to prevent eggs from being set as companion
- Show toast error if user tries to set egg as companion
- Disable companion button for eggs with helpful tooltip
- Update tooltip to show 'Hatch first to set as companion' for eggs
2026-03-23 20:06:35 -03:00
filemon f2e545ff09 Fix companion tag handling and add indicator in Blobbies modal
- Remove current_companion tag entirely when unsetting (instead of empty string)
- Filter out existing current_companion tags before adding new one
- Add Footprints icon indicator in BlobbiSelectorCard for current companion
- Include tooltip 'Current companion' on the indicator icon
- Pass currentCompanion prop through BlobbiSelectorPage and modal
2026-03-23 19:45:02 -03:00
filemon 11142bc96a Implement Set as Companion toggle in Blobbi Hero section
- Add isCurrentCompanion and isUpdatingCompanion props to BlobbiDashboardFloatingControls
- Implement handleSetAsCompanion to toggle current_companion tag on profile
- Show green icon color when Blobbi is the current companion
- Add disabled state support to FloatingActionDef for loading states
- Pass publishEvent to BlobbiDashboard for profile updates
2026-03-23 19:38:26 -03:00
filemon e3d01bc6aa Make Missions modal sections collapsible
- Daily Missions section is now collapsible with chevron toggle
- Hatch Tasks section is collapsible when active
- Evolve Tasks section is collapsible when active
- All sections expanded by default for easy access
- Header shows progress count (e.g., 2/4) for task sections
- Header shows coins earned for Daily Missions section
- Smooth chevron rotation animation on expand/collapse
2026-03-23 18:13:43 -03:00
filemon d3fc1c602a Add Users icon to Blobbi Selector modal title 2026-03-23 18:08:00 -03:00
The Daniel 926ad380f3 feat: show link preview cards in quoted posts instead of raw URLs 2026-03-23 17:02:43 -04:00
filemon b55a9bae43 Add sticky header and close button to Blobbi Selector modal
Apply the same pattern used in other Blobbi modals:
- Sticky header with bg-background
- Explicit DialogClose button in header
- Scrollable content area with flex-1 min-h-0 overflow-y-auto
2026-03-23 17:29:19 -03:00
filemon f8c46d7a11 Add sticky header to BlobbiInfoModal and fix close buttons in all Blobbi modals
- Add sticky header pattern to BlobbiInfoModal (main Blobbi view)
- Add explicit DialogClose button inside each sticky header
- Hide default DialogContent close button with [&>button:last-child]:hidden
- Close button now stays visible when scrolling in all modals

Updated modals:
- BlobbiActionsModal
- BlobbiActionInventoryModal
- BlobbiInventoryModal
- BlobbiMissionsModal
- BlobbiShopModal
- BlobbiInfoModal
2026-03-23 17:21:36 -03:00
filemon 75ca14c900 Fix modal padding and add sticky headers for Blobbi modals
- Add pr-12 to DialogHeader to account for close button, fixing right padding
- Make DialogHeader sticky with proper background for all Blobbi modals
- Structure modals as flex column with min-h-0 for proper scrolling
- Apply pattern to: BlobbiActionsModal, BlobbiActionInventoryModal,
  BlobbiInventoryModal, BlobbiMissionsModal, BlobbiShopModal
2026-03-23 17:10:56 -03:00
filemon 163712471c Improve Blobbi UI mobile responsiveness and add compact coin formatting
- Add formatCompactNumber utility for compact coin display (1.2K, 15.4K, 1.2M)
- Fix BlobbiActionInventoryModal (medicine items) layout for mobile
- Fix BlobbiBottomBar to prevent overflow on narrow screens
- Fix BlobbiInventoryModal item cards for mobile
- Fix BlobbiMissionsModal horizontal scroll and layout issues
- Fix DailyMissionsPanel and TasksPanel for mobile
- Fix BlobbiShopModal and related dialogs for mobile
- Apply compact number formatting to all coin displays
2026-03-23 17:07:16 -03:00
filemon 974cdcccc9 Merge branch 'main' into feat-blobbi 2026-03-23 10:58:25 -03:00
Derek Ross bbe53a4c69 Filter Mentions tab to only show pure mentions, not replies
The Mentions tab now excludes kind 1 reply events (those with NIP-10
reply/root e-tags), showing only pure mentions where someone tagged the
user in a new post. Kind 1111 comments continue to appear in both tabs.
2026-03-22 15:54:41 -04:00
Derek Ross 88d9e783b8 Distinguish replies from mentions in notifications
Kind 1 events that are replies (have NIP-10 reply/root e-tags) now show
a reply icon with 'replied to your note' instead of the '@' icon with
'mentioned you'. Only pure mentions (no reply threading) use the mention
label.
2026-03-22 15:50:11 -04:00
Derek Ross 854f9aca23 Fix notification UX issues: real-time updates, zap/reaction visibility, comment counts
- Fix silent notification drops: reactions, reposts, and zaps were being
  discarded when the referenced event couldn't be fetched from relays.
  Now keeps notifications with missing context instead of hiding them.
  Zaps no longer require the author-ownership check since the #p filter
  already confirms the user is the recipient. (fixes tabs appearing
  identical and missing zap notifications)

- Add real-time WebSocket subscriptions for instant notification updates
  instead of relying solely on 60-second polling. Both the full
  notification list and the unread dot indicator now react immediately
  when new events arrive.

- Wire up zap amounts from NIP-85 stats (zap_amount tag on kind 30383)
  through to the NoteCard action bar, replacing the hardcoded 0.

- Seed client-side reply counts into the event-stats cache from the
  loaded comment tree in PostDetailPage, ensuring sub-comment counts
  are visible even when NIP-85 stats are unavailable for kind 1111.

Closes #136
2026-03-22 15:42:45 -04:00
filemon 69dde41d9c fix(blobbi): rebalance daily mission rewards and persist to kind 31125
- Rebalance rewards: interact_6=30, feed_2=20, clean_1=20, sing_1=25,
  play_music_1=25, sleep_1=20, change_shape_1=40, take_photo_1=35
- Total daily reward now 75-105 coins (was 240-330)
- Add useClaimMissionReward hook to persist coins to Blobbonaut profile
- Claim flow now updates kind 31125 event, not just localStorage
- Add idempotency check to prevent double-crediting
- Update query cache and invalidate after successful claim
- Pass profile and updateProfileEvent to BlobbiMissionsModal
2026-03-21 21:55:18 -03:00
filemon e159e5bb6d feat(blobbi): implement daily missions system
- Add mission pool with 8 missions (interact, feed, clean, sing, play_music, sleep, take_photo, change_shape)
- Implement weighted random selection for 3 daily missions per user
- Add localStorage persistence with automatic daily reset
- Track progress from all Blobbi actions (inventory, direct actions, sleep, photo, profile shape)
- Add DailyMissionsPanel UI component with progress bars and claim buttons
- Integrate daily missions section into BlobbiMissionsModal
- Each mission rewards 80-110 coins depending on difficulty
2026-03-21 21:43:42 -03:00
filemon fce3c81029 fix(blobbi): ensure Blobbi eyes are always open in photo exports
Added disableBlink option to prevent Blobbi from being captured mid-blink
when downloading or posting photos.

Changes:
- useBlobbiEyes: Added disableBlink option that keeps blinkScaleY at 1 (fully open)
- BlobbiBabyVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiAdultVisual: Added disableBlink prop, passed to useBlobbiEyes
- BlobbiStageVisual: Added disableBlink prop, passed to child visuals
- BlobbiPolaroidCard: Now uses disableBlink={true} alongside lookMode='forward'

Photo mode behavior:
- lookMode='forward': Eyes look straight ahead (no mouse tracking)
- disableBlink={true}: Eyes stay fully open (no blinking animation)
- animated={false}: No ambient animations

Normal dashboard behavior unchanged - Blobbi still blinks and follows pointer.
2026-03-21 20:56:13 -03:00
filemon b14717eddb fix(blobbi): ensure polaroid export matches modal preview exactly
The date text was wrapping to a second line in the exported PNG but not
in the modal preview. This was caused by html-to-image rendering text
differently when using Tailwind classes.

Fixes:
- Convert caption area to inline styles for consistent html-to-image export
- Add whitespace: nowrap to date, stage badge, and caption elements
- Add explicit width constraint to caption container
- Use inline styles instead of Tailwind classes for all caption text

This ensures the downloaded PNG and Blossom-posted image match the
modal preview exactly, with the date staying on one line.
2026-03-21 20:50:23 -03:00
filemon 5891014ff6 refactor(blobbi): refine polaroid photo implementation
1. Polaroid layout - now looks like a real polaroid:
   - White frame on ALL sides (top: 16px, sides: 16px, bottom: 80px)
   - Photo area is inset within the white frame
   - Caption area positioned at bottom of frame
   - Consistent off-white background (#fafafa) for export

2. Removed noisy debug logs:
   - Removed [BlobbiStageVisual][baby] and [adult] console.logs
   - These were render-time logs that added noise without value

3. Improved export/post robustness:
   - URL extraction now explicitly finds 'url' tag instead of assuming [0][1]
   - Added error handling if upload returns no URL
   - Safer parsing that doesn't depend on tag order

4. Verified visual export consistency:
   - White frame visible on all sides
   - Blobbi centered in photo area
   - No clipping or gaps
   - Caption properly positioned
2026-03-21 20:39:50 -03:00
filemon b51535bfa0 feat(blobbi): implement Blobbi Photo (Polaroid) feature
Add the ability to take polaroid-style photos of Blobbis and share them:

- Add lookMode prop to Blobbi rendering system:
  - 'follow-pointer': Eyes track mouse cursor (default, existing behavior)
  - 'forward': Eyes look straight ahead (for photos/export)
  - Updated useBlobbiEyes, BlobbiBabyVisual, BlobbiAdultVisual, BlobbiStageVisual

- Create BlobbiPolaroidCard component:
  - Classic polaroid-style frame with white background and shadow
  - Soft gradient background for photo area
  - Caption area with Blobbi name, stage, and date
  - Fixed dimensions (320x400) for consistent export
  - Built with HTML+CSS (not canvas) for easy customization

- Create BlobbiPhotoModal component:
  - Opens from 'Take a Photo' button on BlobbiPage
  - Shows polaroid preview with Blobbi looking forward
  - Download button: exports as PNG using html-to-image
  - Post button: uploads to Blossom and creates kind 1 note
  - Clean, minimal UI focused on the photo

- Wire up to BlobbiPage:
  - Photo modal state and handler
  - Connected to floating 'Take a Photo' action button

Dependencies:
- Added html-to-image for DOM-to-PNG conversion
2026-03-21 20:29:03 -03:00
filemon 8476edd18f chore(blobbi): temporarily hide unimplemented floating action buttons
Hide 'Set as Companion' and 'Open PiP' buttons from BlobbiDashboardFloatingControls
until their features are implemented. Code is commented out with TODO markers for
easy re-enablement later.

Remaining visible buttons: Take a Photo, Info, Hatch/Evolve actions
2026-03-21 20:15:04 -03:00
filemon e528cdb36d feat(blobbi): add stage-aware validation and semantic cleanup
- Detect final stage from tags and validate tags against stage constraints
- Remove tags not valid for the detected stage (e.g., adult_type only on adults)
- Fix state after transitions (incubating/evolving -> active)
- Validate state is valid for the stage (per VALID_STATES_BY_STAGE)
- Skip required tag recovery for tags not valid for current stage
- Skip persistent tag recovery for tags not valid for current stage
- Add dev diagnostics with console.warn when repairs are applied
- Return finalStage in TagRepairResult for caller inspection
2026-03-21 20:08:56 -03:00
filemon 8696c698ed feat(blobbi): implement tag integrity guard for all Blobbi events
Add validateAndRepairBlobbiTags function that ensures tag integrity
whenever a Blobbi is republished. The guard:

1. Validates tags against the canonical schema
2. Removes deprecated tags automatically
3. Removes task-related tags during stage transitions (when requested)
4. Recovers missing required tags from:
   - Previous canonical tags (if available)
   - System defaults (for b, t, client only)
5. Preserves all persistent tags from previous state
6. NEVER invents personality/trait/adult_type values

Integration points:
- mergeBlobbiStateTagsForRepublish: validates all tag merges
- useBlobbiHatch: validates with task cleanup before publishing
- useBlobbiEvolve: validates with task cleanup before publishing
- buildMigrationTags: validates migration output

Repair strategy:
- System tags (b, t, client): recover from defaults
- Identity tags (name, seed, d): recover from previous only, never invent
- Personality tags: preserve if exist, never invent
- Visual tags: preserve (regenerable from seed if needed)
- Stats: preserve current values
- Task tags: cleanup during transitions
2026-03-21 20:02:20 -03:00
filemon c236caefad docs(blobbi): add product spec for Blobbi tag schema
Create docs/blobbi/blobbi-tag-schema.md as the canonical source of truth
for Blobbi tag definitions. The runtime schema at blobbi-tag-schema.ts
MUST align with this spec.

Includes:
- All 35 canonical tags organized into 11 categories
- Required vs optional designation
- Persistence rules across stage transitions
- Stage transition rules (hatch, evolve)
- Migration rules
- Validation rules
- 8 deprecated tags with migration guidance
2026-03-21 19:50:03 -03:00
filemon f9de5282c9 fix(blobbi): preserve personality/extension tags in migration flow
Update buildMigrationTags to preserve all persistent tags when migrating
legacy Blobbis to canonical format:

- personality, trait, favorite_food, voice_type, mood
- adult_type
- theme, crossover_app

Per blobbi-tag-schema.md spec: Do NOT invent values for tags that don't
exist. Only preserve values that are already present in the legacy event.

Also adds docs/blobbi/blobbi-tag-schema.md as the product spec for all
Blobbi tag definitions. The runtime schema in blobbi-tag-schema.ts MUST
align with this spec.
2026-03-21 19:49:53 -03:00
filemon a4c2895c68 docs(blobbi): add canonical tag schema for Blobbi events
Create blobbi-tag-schema.ts as the single source of truth for all
Blobbi tag definitions. The schema documents:

- 35 canonical tags organized into 11 categories
- Tag metadata: required, stages, persistent, source, regenerable
- 8 deprecated tags with migration guidance
- Helper functions for schema queries and validation

Categories:
- system: d, b, t, client
- identity: name, seed, generation
- visual: base_color, secondary_color, eye_color, pattern, special_mark, size
- personality: personality, trait, favorite_food, voice_type, mood
- stats: hunger, happiness, health, hygiene, energy
- state: stage, state, last_interaction, last_decay_at
- task: state_started_at, task, task_completed
- progression: experience, care_streak
- social: visible_to_others, breeding_ready
- evolution: adult_type
- extension: theme, crossover_app

Also updates MANAGED_BLOBBI_STATE_TAG_NAMES to include extension
tags (theme, crossover_app) and reorganizes comments by category.
2026-03-21 19:39:34 -03:00
filemon e33ee800bc fix(blobbi): preserve identity attributes across stage transitions
- Add identity/personality tags (personality, trait, favorite_food,
  voice_type, mood, adult_type) to MANAGED_BLOBBI_STATE_TAG_NAMES
- Add 'interact_6_progress' to DEPRECATED_BLOBBI_TAG_NAMES to remove
  legacy interaction tracking
- Update hatch flow to clean only task/state-specific tags while
  preserving all identity attributes from canonical.allTags
- Update evolve flow to clean only task/state-specific tags while
  preserving all identity attributes, and set state to 'active'

This ensures Blobbi identity persists across egg → baby → adult
transitions, treating them as persistent entities rather than
reconstructed objects at each stage.
2026-03-21 19:35:53 -03:00
filemon 24aa80840c Merge branch 'main' into feat-blobbi 2026-03-21 19:24:47 -03:00
The Daniel d9aa6258cd fix: allow nsec paste in login field 2026-03-21 18:07:46 -04:00
The Daniel 941e6ee4e6 feat: block nsec paste in all input fields with warning toast
Adds a global capture-phase paste listener that detects nsec private
keys and prevents them from being pasted into any field. Shows a
destructive toast warning the user that private keys should never
be shared.
2026-03-21 18:03:17 -04:00
filemon ffab9d3aaa feat(blobbi): add dev-only instant stage transition button
- Add 'Dev Hatch' button for eggs (bypasses incubation tasks)
- Add 'Dev Evolve' button for babies (bypasses evolution tasks)
- Button only appears in dev mode (import.meta.env.DEV)
- Uses dashed amber border style to clearly indicate dev-only
- Reuses existing onHatch/onEvolve callbacks
- No button shown for adults (no further transitions)
2026-03-20 18:15:30 -03:00
filemon 4d16e1ab83 Merge branch 'main' into feat-blobbi 2026-03-20 18:13:32 -03:00
filemon 6781685252 update typo in epsy url in blobbi 2026-03-20 18:03:52 -03:00
filemon 88bdf87e95 feat(blobbi): reduce evolve post requirement from 3 to 1
Make evolve process lighter and less repetitive:
- EVOLVE_REQUIRED_POSTS: 3 → 1
- Update task name: 'Create Posts' → 'Share Evolution'
- Update task description: 'Share 3 posts about evolving' → 'Post about your Blobbi evolving'
- Reduce query limit since only 1 post needed

Hatch process unchanged - still requires 3 posts with hatch-specific validation
2026-03-20 17:29:14 -03:00
filemon 6b3d98bd66 refactor(blobbi): unify task process with useActiveTaskProcess hook
- Add useActiveTaskProcess hook to consolidate hatch/evolve task logic
- Rename useSyncHatchTaskCompletions to useSyncTaskCompletions
- Fix badge to include dynamic tasks (was only counting persistent)
- Fix 'Edit Wall' task link to point to /settings/profile
- Reduce duplication in BlobbiPage.tsx by using unified hook
- Export new hook and types from blobbi/actions index
2026-03-20 16:07:38 -03:00
filemon ec7ceb2352 fix(blobbi): fix task tag writing and unify badge logic for both processes
BUG 1 - task/task_completed tags not being written:
- incrementInteractionTaskTags now accepts requiredInteractions param
  (7 for hatch, 21 for evolve) instead of hardcoded value
- useBlobbiDirectAction and useBlobbiUseInventoryItem now increment
  interactions for BOTH incubating AND evolving states
- useSyncHatchTaskCompletions now syncs for both processes

BUG 2 - evolving missions not affecting badge:
- remainingTasksCount now uses active process tasks (hatch or evolve)
- allTasksComplete now checks both incubating and evolving states
- BlobbiBottomBar uses isInTaskProcess instead of isIncubating

Architecture rules preserved:
- Persistent tasks: can be cached in task/task_completed tags
- Dynamic tasks: NEVER stored in tags, UI-only
- No infinite loops, no retroactive increments
- Interactions only increment during real user actions
2026-03-20 16:01:15 -03:00
filemon c62c38b136 feat(blobbi): implement evolve task system with dynamic stat requirements
Add comprehensive evolution task system parallel to incubation:

Architecture:
- Separate persistent tasks (event-based, cacheable in tags) from
  dynamic tasks (stat-based, never cached, recomputed every render)
- Both hatch and evolve require all persistent AND dynamic tasks complete

New hooks:
- useEvolveTasks: 6 persistent tasks + 1 dynamic stat task (all stats >= 80)
- useStartEvolution/useStopEvolution: manage evolution state transitions

New components:
- TasksPanel: generalized task display for both hatch and evolve
- StartEvolutionDialog: confirmation dialog for starting evolution
- Updated BlobbiMissionsModal to handle both hatch and evolve flows

Evolve tasks (baby → adult):
- Create 3 themes, 3 color moments, 3 evolve posts
- 21 interactions, use Blobbi shape, edit wall once
- Dynamic: maintain all 5 stats >= 80

Key fixes:
- Renamed isEvolving variable collision in BlobbiPage
- Filter dynamic tasks from sync to prevent tag pollution
- Updated BlobbiPostModal to support evolve posts dynamically
2026-03-20 15:31:02 -03:00
DanConwayDev 33bf59f353 fix: remove unexported NLoginBunker import and prefer-const lint error 2026-03-20 17:22:38 +00:00
DanConwayDev b4d3c4833c feat: remote signer UX improvements for Amber/NIP-46 users on Android
Amber users on Android who manually approve events must switch from Ditto to
Amber to approve, then switch back. Backgrounding Ditto can freeze its
WebSocket, causing the NIP-46 response to be silently dropped — leaving the
operation hanging with no feedback and no way out. Users with Amber
notifications working correctly are unaffected, as approving via notification
does not background Ditto.

Even with auto-approve enabled, kinds outside Amber's default whitelist
require manual approval. Ditto uses several of these regularly: kind 1059
(NIP-17 gift-wrap DMs), 1111 (comments), 1311 (live chat), 31925 (RSVPs),
24242 (Blossom file upload auth), and 30078 (app settings).

This introduces signerWithNudge, a NostrSigner wrapper with the following
behaviour:

Nudge toast after 4 seconds
If a signing or encryption op is still pending after 4 s, a persistent toast
appears naming what is being approved (e.g. 'Approve file upload auth'), with
a human-readable label derived from the event kind.

Android 'Approve in signer' button
On Android the nudge toast includes an 'Approve in signer' button that opens
Amber via the nostrsigner: URI scheme, keeping the WebSocket alive. After
tapping, the button becomes a spinner and a Cancel button appears.

Automatic retry on foreground resume
When Ditto returns to the foreground after being backgrounded, it
automatically retries the pending NIP-46 request (up to 2 times) and shows a
brief 'Checking for signer response' toast.

Hard 45-second timeout
Operations with no response within 45 s are rejected with a clear error.

Cancel / Skip
The nudge toast has a Skip link throughout. After tapping 'Approve in signer'
it becomes a full Cancel button.

Multi-phase encrypt-then-sign
Saving app settings (kind 30078) and mute lists (kind 10000) require a nip44
encrypt followed immediately by a signEvent. When the encrypt nudge was shown,
a phase-transition toast tells the user a second approval is coming. The check
is kind-specific to avoid false positives.

Success feedback
A brief 'Approved' toast confirms the outcome when the nudge was shown.

Relay connectivity check
At nudge time, if the bunker relay WebSocket is not OPEN the toast warns
'Signer relay unreachable' instead of prompting for an approval that cannot
be delivered.

Accessibility
Toast buttons meet the 44 px touch target minimum. Text size and contrast
were increased for readability on small screens.
2026-03-20 17:22:37 +00:00
filemon 44b54f6c32 fix(blobbi): fix remainingTasksCount memo and allTasksComplete condition
- remainingTasksCount now depends on hatchTasks.tasks (not just completedTaskIds)
  to correctly reflect loading state and task changes
- allTasksComplete is now a memoized value that prevents false positives by
  checking: isIncubating && !isLoading && tasks.length > 0 && remaining === 0
- Changed missions badge symbol from '?' to '!' when all tasks complete
2026-03-20 12:02:12 -03:00
filemon 57f7af3141 refactor(blobbi): stabilize task sync, fix hatch content, improve UI indicators
Task Sync Stability:
- Remove hatchTasks.tasks from useEffect dependencies (was causing instability)
- Derive tasksToSync and remainingTasksCount via useMemo keyed off completedTaskIds
- Effect dependencies now only include stable primitives: completedTaskIds,
  cachedCompletedIds, hatchTasks.isLoading, companion?.state

Content Fix on Stage Transition:
- Add generateBlobbiContent() helper with correct grammar ('an egg' vs 'a baby')
- useBlobbiHatch now generates new content: '{name} is a baby Blobbi.'
- useBlobbiEvolve now generates new content: '{name} is an adult Blobbi.'
- Content always reflects current stage (was keeping old egg content)

Missions Icon Badge:
- Show remaining task count when incubating with tasks remaining
- Show '?' badge (success variant) when all tasks complete during incubation
- Badge variants: default (blue), warning (amber), success (emerald)

Blobbies Icon Badge:
- Now shows count of Blobbies needing care (any stat < CARE_THRESHOLD=40)
- Warning variant (amber) when there are needy Blobbies
- Only shows badge when count > 0

Selector Modal Warning:
- Add AlertTriangle indicator in top-right corner for Blobbies needing care
- Uses same companionNeedsCare() logic as bottom bar badge
2026-03-20 11:44:48 -03:00
filemon 23dbd9112a fix(blobbi): prevent infinite loop in task sync, add missions badge
Infinite Loop Fix:
- Add useRef anti-loop memory (lastSyncedKeyRef) to track last synced key
- Mark key as synced BEFORE calling sync to prevent race conditions
- Add guard for companion.state !== 'incubating'
- Remove syncTaskCompletions from useEffect deps (intentional, prevents
  re-triggering when mutation function reference changes)
- Reset ref on error to allow retry

Missions Badge:
- Add remainingTasksCount prop to BlobbiBottomBar
- Show badge on Missions button when incubating with incomplete tasks
- Update BottomBarButton to show badge when count > 0 (was > 1)

The interaction task tags were already being updated correctly by
incrementInteractionTaskTags() in useBlobbiDirectAction and
useBlobbiUseInventoryItem hooks during real user interactions.
2026-03-20 11:19:41 -03:00
filemon 44c8103600 refactor(blobbi): make incubation flow explicit, require Blobbi name in posts
- useStartIncubation now requires explicit mode ('start', 'restart', 'switch')
  instead of auto-detecting behavior. This makes the flow predictable.
- StartIncubationDialog determines mode and passes it to onConfirm callback
- Removed useUpdateTaskProgress hook (architecturally inconsistent - updated
  last_interaction during cache-only sync, violating the rule that only real
  user actions should update timestamps)
- BlobbiPostModal now requires blobbiName and process props for stage-aware
  post generation with Blobbi name as first hashtag
- isValidBlobbiPost() now validates the Blobbi name hashtag is present
- Added sanitizeToHashtag helper to both BlobbiPostModal and useHatchTasks
  for consistent hashtag generation
2026-03-20 10:28:10 -03:00
filemon d2441b345b fix(blobbi): make task sync fully idempotent and safe
Audit and hardening of hatch task cache sync:

1. BlobbiPage useEffect:
   - Use useMemo to create stable string keys for completion comparison
   - Only trigger sync when computed completions differ from cached
   - Skip sync entirely if no diff exists
   - Add dev-only debug logs

2. useSyncHatchTaskCompletions:
   - Remove last_interaction update (this is cache sync, not user action)
   - Add double diff check: first against companion.tasksCompleted, then against canonical.allTags
   - Return detailed result with skip reasons for debugging
   - Add dev-only debug logs for all sync decisions

3. incrementInteractionTaskTags:
   - Add check for already-completed state to prevent duplicate task_completed tags
   - Return previousCount for debugging
   - Add dev-only debug logs
   - Document that this is NOT idempotent by design (each call = real interaction)

Key guarantees:
- Cache-only sync never mutates last_interaction
- Multiple renders with same data = no publish
- WebSocket updates or refetches = no publish unless real diff
- No duplicate task_completed tags possible
2026-03-20 10:13:19 -03:00
filemon 2c828c8778 feat(blobbi): add missions modal, single incubation enforcement, and task sync
Phase 2 of Blobbi incubation audit:

- Add BlobbiMissionsModal component with HatchTasksPanel integration
- Move hatch tasks UI from main page to Missions modal
- Add useStopIncubation hook with confirmation dialog
- Enforce only one Blobbi incubating at a time (auto-stops previous)
- Enhance StartIncubationDialog to show switch warning for other incubating Blobbi
- Add useSyncHatchTaskCompletions hook to sync task completions to kind 31124 tags
- Consolidate STAT_MIN/STAT_MAX to single source in src/lib/blobbi.ts
- Remove unused BlobbiPlaceholderModal component
2026-03-20 09:59:50 -03:00
filemon 8097f0e5fb fix(blobbi): fix selection reset bug, stat safety, and migration visual traits
- Remove auto-save effect that was overwriting user selection during
  WebSocket/query updates. User selection now only persists via explicit
  handleSelectBlobbi() call.
- Add debug logging to trace selection changes in development mode.
- Add STAT_MIN=1 and STAT_MAX=100 constants to blobbi-decay.ts and update
  clamp() to use STAT_MIN instead of 0, preventing soft-lock issues.
- Fix buildMigrationTags() to always derive and include all visual traits
  during migration, ensuring every migrated event has complete visual data.
2026-03-20 09:47:11 -03:00
filemon 14644d0cb3 Clean up deprecated tags in migration and republish flows
- Remove incubation_time and start_incubation from coreStateTags in
  buildMigrationTags - these obsolete fields should not be carried
  forward during migration
- Add DEPRECATED_BLOBBI_TAG_NAMES to the exclusion set in buildMigrationTags
  when filtering unknown tags, preventing deprecated tags from being
  preserved in migrated events
- Add DEPRECATED_BLOBBI_TAG_NAMES filtering to mergeTagsForRepublish so
  deprecated tags are not carried forward when republishing existing events

The deprecated tags (incubation_time, start_incubation, incubation_progress,
egg_status, fees) are now properly excluded from:
1. New events (already handled by buildEggTags)
2. Migrated events (buildMigrationTags)
3. Republished events (mergeTagsForRepublish, mergeBlobbiStateTagsForRepublish)

Legacy parsing still reads these fields for backwards compatibility, but
they are never written to new events.
2026-03-19 13:06:31 -03:00
filemon 4c89a20bbe Fix critical bug: Blobbis disappear after starting incubation
The isValidBlobbiEvent function only accepted 3 states (active, sleeping,
hibernating) but BlobbiState type includes 5 states. When state changed to
'incubating' or 'evolving', events failed validation and were filtered out
by useBlobbisCollection and useBlobbiCompanion hooks.

Added 'incubating' and 'evolving' to the valid states list.
2026-03-19 11:50:45 -03:00
filemon 6a2285ef72 Clean up Blobbi incubation system and enforce consistency
Deprecated tags removed from creation:
- incubation_time: No longer created in buildEggTags or previewToEventTags
- start_incubation: No longer in managed tags
- egg_status: Removed from LEGACY_VISUAL_TAG_NAMES (was duplicated)

These tags are now in DEPRECATED_BLOBBI_TAG_NAMES and stripped on republish.

Visual trait consistency:
- buildEggTags now includes all visual traits (base_color, secondary_color,
  eye_color, pattern, special_mark, size) derived from seed
- VISUAL_TRAIT_TAG_NAMES replaces LEGACY_VISUAL_TAG_NAMES
- Visual traits added to MANAGED_BLOBBI_STATE_TAG_NAMES

Stat safety:
- STAT_MIN = 1 (was 0) to prevent soft-lock
- STAT_MAX = 100
- clampStat now clamps to 1-100 range instead of 0-100
- Recovery is always possible with any healing item

Layout improvements:
- BlobbiPage container: max-w-2xl on mobile, max-w-3xl on desktop
- Reduced side whitespace on mobile (px-2)
- Better breathing room overall

BlobbiCompanion interface:
- incubationTime and startIncubation marked as @deprecated
- DEFAULT_INCUBATION_TIME marked as @deprecated

state_started_at remains the single source of truth for process timing.
2026-03-19 09:55:26 -03:00
filemon 37a791f113 Fix incubation-start flow to properly apply decay and clean obsolete tags
- Apply accumulated decay from last_decay_at to now before state change
- Write decayed stat values into the incubation-start event
- Set last_decay_at = state_started_at = last_interaction for consistency
- Add incubation_progress, egg_status, fees to DEPRECATED_BLOBBI_TAG_NAMES

The incubation-start event now has consistent timestamps:
- created_at: NOW
- state_started_at: NOW
- last_interaction: NOW
- last_decay_at: NOW (was incorrectly preserving old value)
2026-03-19 09:39:03 -03:00
filemon 06f820d355 Refine Blobbi incubation/hatch task system
- Fix Color Moment URL: espy.social -> espy.you
- Improve shape-change task detection: only counts true post-start changes
- Remove duplicate Start Incubation button, integrate into evolve/hatch button
- Extract shared incrementInteractionTaskTags helper for code reuse
- Update floating controls to show incubation action for eggs not yet incubating

The evolve/hatch button now serves as the single entry point:
- Egg (not incubating): Opens incubation dialog
- Egg (incubating): Button hidden, hatch action in HatchTasksPanel
- Baby: Evolves to adult
2026-03-19 09:04:08 -03:00
filemon 3a2571ccc6 Merge branch 'main' into feat-blobbi 2026-03-19 08:08:37 -03:00
filemon dd614e6a6a Merge branch 'main' into feat-blobbi 2026-03-18 18:00:55 -03:00
filemon f5595b3477 feat(blobbi): implement hatch task system for egg incubation
Add task-based progression system for hatching Blobbi eggs:

- Extend BlobbiState with 'incubating' and 'evolving' states
- Add task tracking fields to BlobbiCompanion interface
- Create useStartIncubation hook to begin incubation process
- Create useHatchTasks hook to compute task progress from Nostr events
- Add HatchTasksPanel UI component showing 5 hatch tasks:
  - Create Theme (kind 36767)
  - Color Moment (kind 3367)
  - Change Avatar Shape (kind 0 with shape change)
  - Create Post with required prefix/hashtags
  - 7 Blobbi interactions
- Add BlobbiPostModal with enforced content requirements
- Add StartIncubationDialog confirmation dialog
- Auto-increment interaction counter in action hooks
- Gate hatch button visibility based on task completion
2026-03-18 16:44:38 -03:00
filemon e9c34df51e fix(blobbi): show aggregated stat preview in inventory use dialog
The inventory modal's use confirmation dialog was showing simple
multiplication (effect * quantity) instead of the actual clamped
values that would be applied.

Now it simulates the sequential application of effects, clamping
at each step, to show the true total effect that will be applied
when confirming. This matches the behavior of the action modal's
confirmation dialog.
2026-03-18 11:34:25 -03:00
filemon 82b9964a61 feat(blobbi): improve item usage UX with quantity selector and inventory actions
- Add quantity selector to item usage modal (feed/play/clean/medicine)
  - Users can now use multiple items at once
  - Effects are applied sequentially with proper clamping at each step
  - Shows estimated total effect preview

- Add 'Use' button to inventory modal
  - Items can now be used directly from inventory
  - Reuses same logic as normal item usage flow
  - Opens confirmation dialog with quantity selector

- Add stage-based item blocking in inventory
  - Eggs cannot use food or toys (shown disabled with reason)
  - Shell Repair Kit only usable by eggs (blocked for baby/adult)
  - Blocked items remain visible but cannot be used

- Shell Repair Kit visibility in medicine modal
  - Only appears for eggs, hidden for other stages

- Make shop modal narrower (max-w-4xl to max-w-2xl)

- Add centralized item usability logic (canUseItemForStage)
  - Single source of truth for item/stage restrictions
  - Exported from actions module for reuse
2026-03-18 11:24:07 -03:00
filemon a0cb5cc307 Merge branch 'main' into feat-blobbi 2026-03-18 10:51:45 -03:00
filemon 0b26ef51a2 feat: add pot to Rosey shape 2026-03-18 10:50:44 -03:00
filemon da3cc5f997 fix: apply shape mask to hover/edit overlay in ProfileCard
The hover overlay was showing as a full rectangle instead of respecting
the Blobbi shape mask. This happened because the overlay was using the
sync getAvatarMaskUrl() which returns empty for Blobbi shapes (they now
render asynchronously as PNG).

Changed ProfileCard to:
- Use getAvatarMaskUrlAsync() for the overlay mask
- Load mask URL via useEffect with proper cleanup
- Apply the same mask to the hover overlay as the avatar itself

Now the hover/edit darkening effect respects the actual avatar shape,
only appearing inside the visible silhouette.
2026-03-18 10:44:28 -03:00
filemon 96356eb804 fix: render Blobbi masks to PNG via canvas for reliable CSS mask-image
CSS mask-image with SVG data URLs doesn't work reliably for complex SVGs
with transforms in some browsers. The picker preview works because it renders
inline SVG directly, but the avatar mask-image was failing for shapes like
droppi, flammi, leafy, mushie, owli, rocky, and rosey.

Changes:
- Rewrite getBlobbiMaskUrl() to render SVG to canvas and export PNG
- Use Blob URL for SVG loading (more reliable than data URL)
- Add async mask generation with proper caching and deduplication
- Update Avatar component to load masks asynchronously via useEffect
- Add getAvatarMaskUrlAsync() for async mask URL retrieval

The picker preview continues to use inline SVG (which works fine),
while the avatar mask now uses rasterized PNG (which works everywhere).
2026-03-18 10:36:27 -03:00
filemon 80067a212c fix: inject fill/stroke attributes directly into SVG elements for mask rendering
The previous approach using <style> tags with CSS selectors inside SVG data URLs
didn't work reliably when the SVG was used as a CSS mask-image. Some browsers
don't process CSS inside SVG data URLs correctly.

This fix:
- Adds injectWhiteFillStroke() function to directly inject fill="white" and
  stroke="white" attributes into each SVG shape element
- Properly handles elements with transform attributes (the root cause of
  droppi, flammi, leafy, mushie, owli, rocky, rosey failing)
- Preserves fill="none" for stroke-only elements (like catti's tail)
- Works reliably across all browsers since it uses SVG attributes, not CSS
2026-03-18 10:28:08 -03:00
filemon d962d7952b fix: remove hardcoded white stroke from catti tail and fix avatar mask updates
1. Remove stroke="white" from catti shape - let the styling apply colors
2. Fix Avatar component to properly update mask when shape changes:
   - Compute maskUrl outside useMemo so it's always fresh
   - Use maskUrl directly in dependencies instead of shape string
   - This ensures the mask updates immediately when selecting a new shape
2026-03-18 10:21:39 -03:00
filemon ae4847ce50 fix: use SVG data URL directly for Blobbi mask instead of canvas rendering
The previous implementation tried to render SVG to canvas synchronously,
but Image loading is asynchronous, causing getBlobbiMaskUrl() to return
empty string and avatars to fall back to squares.

CSS mask-image supports SVG data URLs directly, so we now skip the
canvas conversion entirely. This is simpler, more reliable, and works
synchronously.

- Remove renderSvgToMaskUrl() and drawImageToCanvas() functions
- Simplify getBlobbiMaskUrlAsync() to just wrap sync version
2026-03-18 10:15:45 -03:00
filemon a07a2de786 fix: scope Blobbi shape colors and increase preview size
- Use fill/stroke attributes on <g> instead of global CSS to prevent color leakage
- Reduce grid columns from 5 to 4 for larger shape previews
- Reduce inner padding from inset-1 to inset-0.5 for bigger shapes
2026-03-18 10:09:08 -03:00
filemon 96ce34c7f1 refactor: use raw SVG markup for Blobbi shapes instead of single path
- Change BlobbiShape type from 'path: string' to 'svg: string'
- Store original SVG body markup preserving circles, ellipses, rects, paths, transforms, and strokes
- Update getBlobbiMaskUrl() to render SVG string via Image element instead of Path2D
- Add getBlobbiMaskUrlAsync() for guaranteed async loading
- Add getBlobbiShapeSvg() helper to get complete SVG markup with custom fill
- Update BlobbiShapePicker to render multi-element SVG with dangerouslySetInnerHTML
- Shapes now visually match original SVG files exactly (e.g., catti tail is stroke-based)
2026-03-18 09:50:13 -03:00
filemon c6fa1acf66 refactor: improve Blobbi shape picker UI and update character silhouettes
- Add tight viewBox computation for better shape visibility in picker
- Change grid layout to 5 columns for larger, more prominent shapes
- Remove shape name labels for cleaner picker interface
- Rename 'Blobbi' tab to 'Blobbids' in avatar picker dialog
- Update all adult Blobbi SVGs with refined designs
- Redesign shape paths with detailed silhouettes including limbs and accessories
- Add pot to Leafy, adjust Pandi arms, remove shadows from Rocky
2026-03-18 09:44:23 -03:00
filemon a7c29c4a85 feat: add Blobbi shapes as avatar masks
New feature allowing users to select Blobbi character silhouettes as
avatar masks, in addition to existing emoji shapes.

New files:
- src/lib/blobbiShapes.ts: Shape definitions with SVG paths for all
  Blobbi forms (egg, baby, 16 adults)
- src/components/BlobbiShapePicker.tsx: Grid picker with category tabs

Changes:
- Extended avatarShape.ts to support 'blobbi:' prefixed shape values
- Added getAvatarMaskUrl() unified function for both shape types
- Updated Avatar component to render Blobbi masks
- Added tabbed UI in ProfileCard (Emoji/Blobbi tabs)
- Renamed emojiAvatarBorderStyle to shapedAvatarBorderStyle

Shape format: 'blobbi:<id>' (e.g., 'blobbi:baby', 'blobbi:catti')
Stored in kind-0 metadata as 'shape' property, same as emojis.

Available shapes:
- Egg (simple egg silhouette)
- Baby Blobbi (water droplet)
- Adults: catti, owli, froggi, droppi, flammi, crysti, cloudi,
  mushie, starri, pandi, cacti, breezy, leafy, rocky, rosey, bloomi
2026-03-17 12:32:46 -03:00
filemon 91364385c3 fix: use data attributes for blink center instead of CSS parsing
Changes:
- Store blink center as data-cx/data-cy attributes on .blobbi-blink groups
- Use eye white center (cx, cy) as blink anchor when available
- Fallback to pupil center if no eye white found
- Read center from data attributes in animation loop (more reliable than CSS)

Structure per eye:
<g class="blobbi-blink" data-cx="38" data-cy="45">  <!-- blink group -->
  <ellipse ... />                                        <!-- eye white -->
  <g class="blobbi-eye">                               <!-- tracking group -->
    <circle ... />                                       <!-- pupil -->
    <circle ... />                                       <!-- highlight -->
  </g>
</g>

Blink transform: translate(cx,cy) scale(1,blinkY) translate(-cx,-cy)
- Scales around the actual eye center from SVG element data
- No CSS transform-origin parsing needed
- Eye closes in place without shifting
2026-03-17 12:21:02 -03:00
filemon d958722e63 fix: scale blink around eye center to prevent downward shift
Problem: scale(1 blinkY) was scaling from top-left origin, causing
the eye to move down during blink.

Solution: Use translate-scale-translate pattern to scale around the
eye's center point:
  translate(cx cy) scale(1 blinkY) translate(-cx -cy)

The center coordinates are extracted from the transform-origin style
that was already set during SVG processing.

Tracking behavior remains completely unchanged - only the blink
transform application was modified.
2026-03-17 12:11:10 -03:00
filemon d3a19ebfaa fix: separate tracking and blink into distinct transform groups
Problem: Blink was only affecting pupil, not whole eye.
Previous attempt: Wrapping entire eye in one group caused eye white to
move with mouse tracking.

Solution: Two separate nested groups per eye:
- .blobbi-blink (outer): wraps entire eye for scaleY blink animation
- .blobbi-eye (inner): wraps only pupil+highlight for translate tracking

Structure:
<g class="blobbi-blink">     <!-- blink: scale(1 blinkY) -->
  <ellipse ... />             <!-- eye white - stays fixed -->
  <g class="blobbi-eye">    <!-- tracking: translate(x y) -->
    <circle ... />            <!-- pupil - moves with mouse -->
    <circle ... />            <!-- highlight - moves with mouse -->
  </g>
</g>

Result:
- Mouse tracking: only pupil+highlight translate (unchanged behavior)
- Blinking: entire eye scales vertically (natural cartoon blink)
- Eye white: never moves, only scales during blink
2026-03-17 12:01:08 -03:00
filemon c17883bdb8 feat: add natural blinking system for Blobbi eyes
- Random blink intervals between 2-5 seconds for organic feel
- Blink animation: fast close (~80ms), pause (~100ms), slower open (~120ms)
- 20% chance for double blinks (extra polish)
- Uses scaleY transform combined with mouse tracking translate
- Easing functions: ease-in for close, ease-out for open
- Disabled when Blobbi is sleeping
- No CSS transitions - all animation via RAF for instant response
2026-03-17 11:42:40 -03:00
filemon 0b90b0206b fix: eliminate eye tracking lag with direct SVG transforms
- Remove CSS transitions from .blobbi-eye class (root cause of delay)
- Use SVG transform attribute instead of style.transform for reliable repaints
- Cache eye element references after mount with automatic refresh on SVG changes
- Hook now manages DOM directly without onUpdate callback
- Simplified visual components to just pass containerRef
2026-03-17 11:34:58 -03:00
filemon 8f958ef6c7 refactor: simplify eye system to always track mouse globally
Removed from previous system:
- Idle random movement logic
- Energy-based behavior (timing, smoothing, micro-movements)
- Tracking radius (200px distance check)
- Idle/tracking state switching
- lerp interpolation for tracking
- isTracking state and callback parameter
- Per-instance mouse listeners

New behavior:
- Eyes ALWAYS follow the mouse cursor
- Works across the entire screen (no distance limit)
- Instant response (no interpolation, no lag)
- Simple angle calculation every frame

How the new tracking loop works:
1. Global mouse listener updates globalMouseX/globalMouseY
2. RAF loop runs every frame
3. Calculate angle: atan2(mouseY - centerY, mouseX - centerX)
4. Calculate position: cos(angle) * max, sin(angle) * max * 0.7
5. Call onUpdate callback with position
6. DOM updated directly (no React state)

Performance optimizations:
- Single global mouse listener shared by all Blobbi instances
- Instance count tracking for cleanup
- No React state in animation loop
- Direct DOM manipulation via callback
- Minimal computation per frame (just trig)

Hook reduced from ~390 lines to ~140 lines.
2026-03-17 10:35:44 -03:00
filemon 2118fa483b fix: remove React state from animation loop for real-time eye tracking
Problems fixed:

1. React state caused tracking delay
   - setState() batches updates and triggers re-renders
   - Even with refs, calling setState inside RAF caused 1-2 frame lag
   - Eyes only followed mouse properly when it stopped moving

2. Tracking intensity was inverted
   - Old: farther mouse = stronger movement (wrong)
   - New: closer mouse = stronger movement (correct)
   - Formula: intensity = 1 - Math.pow(normalizedDistance, 0.5)

3. Idle froze after mouse left
   - Old: scheduled idle change 300-800ms later
   - New: immediately triggers new idle target when tracking stops
   - Formula: nextIdleChangeRef.current = currentTime (force immediate)

Solution - callback-based architecture:

- REMOVED all setState calls from animation loop
- Added onUpdate(left, right, isTracking) callback option
- Callback is called every RAF frame with current positions
- Components apply transforms directly to DOM via querySelectorAll
- Zero React re-renders during animation = zero lag

Data flow now:
  RAF loop → compute position → onUpdate callback → direct DOM update

Before:
  RAF loop → setState → React re-render → useEffect → DOM update

The onUpdate callback receives positions every frame and applies
transforms immediately, bypassing React's batching entirely.
2026-03-17 10:29:25 -03:00
filemon 2bb9c7738a feat: instant mouse tracking and energy-based idle behavior for Blobbi eyes
1. Mouse tracking now INSTANT (no lag):
   - When tracking, eyes lock directly onto cursor position
   - No interpolation/lerp during tracking mode
   - Feels like 'locked on target' instead of floating/chasing

2. Energy-based idle behavior:
   - High energy (100): frequent movement, shorter pauses, quicker smoothing
   - Low energy (0): lazy movement, longer pauses, slower drift
   - Energy affects: idle duration, smoothing speed, micro-movement chance

3. Micro-movements for aliveness:
   - Small movements (0.2-0.5px) happen randomly
   - High energy = 50% chance, Low energy = 10% chance
   - Makes Blobbi feel alert and curious

4. Pause behavior scaled by energy:
   - Low energy: 50% chance to rest at center
   - High energy: 10% chance to rest at center

Values chosen:
- SMOOTHING_MIN = 0.02 (low energy - dreamy drift)
- SMOOTHING_MAX = 0.06 (high energy - alert movement)
- IDLE_DURATION_MIN = 1000ms (high energy)
- IDLE_DURATION_MAX = 6000ms (low energy)
- MICRO_MOVEMENT_MAX = 0.5px (subtle but visible)

Behavior summary:
- Mouse near -> eyes LOCK instantly on cursor
- High energy -> curious, active, moving often
- Low energy -> slower, lazy, but still alive
- Sleeping -> no movement
2026-03-17 10:17:37 -03:00
filemon 7ed55b00a6 fix: smooth eye animation with proper interpolation and mouse tracking
Root causes of previous issues:

1. Jumping behavior: The old implementation used setState() directly to
   random positions instead of interpolating toward them. Each idle
   movement instantly teleported the eyes.

2. Mouse tracking failure: The updateMouseTracking callback had isTracking
   in its dependencies, causing it to be recreated on every state change.
   This restarted the animation frame loop constantly, breaking the
   continuous tracking.

3. State conflict: Idle timeouts and tracking animation frames ran
   independently and fought each other, causing erratic behavior.

Solution - Single animation loop architecture:

- ONE requestAnimationFrame loop handles ALL animation
- Maintains separate 'current' and 'target' positions
- Always interpolates: current = lerp(current, target, smoothing)
- Idle behavior only sets new targets (doesn't move directly)
- Mouse tracking overrides targets when cursor is nearby
- Clean state machine: tracking active = idle paused

Smoothing values used:
- IDLE_SMOOTHING = 0.03 (very smooth drift)
- TRACKING_SMOOTHING = 0.08 (responsive but not snappy)
- RETURN_SMOOTHING = 0.04 (gentle return to idle)

Timing improvements:
- Idle duration: 3-6 seconds between movements
- 40% chance to pause at center (natural resting)
- Time-scaled smoothing for consistent feel across frame rates

Movement constraints:
- Baby: 2px max, Adult: 2.5px max
- Vertical movement reduced to 70% of horizontal
- State updates throttled (only when position changes > 0.001px)
2026-03-17 10:08:58 -03:00
filemon 4feb051177 fix: rewrite eye animation system for proper SVG transforms and mouse tracking
Root cause: The original implementation had two critical issues:
1. Grouping algorithm assumed highlights immediately followed pupils in SVG,
   but SVGs have all pupils first, then all highlights (proximity-based fix)
2. CSS transforms weren't working on SVG <g> elements without transform-box

Fixes:
- Rewrite pupil/highlight detection to use proximity-based grouping (15px radius)
- Add transform-box: fill-box and transform-origin: center inline styles
- Replace CSS keyframe animation with JavaScript-controlled transforms

New features:
- Natural idle behavior with random movement and pauses
- Mouse tracking when cursor is within 200px radius
- Smooth transitions between idle and tracking states
- Different delays for left/right eyes for organic feel

Implementation:
- useBlobbiEyes hook manages animation state and mouse tracking
- addEyeAnimation wraps pupil+highlight elements in <g class="blobbi-eye">
- Visual components apply transforms via DOM refs in useEffect
- CSS provides transition timing (.3s idle, .1s tracking)
2026-03-17 09:47:39 -03:00
filemon 27bce0d334 feat: add subtle eye movement animation for Blobbi baby and adult visuals
Add eye animation utility that:
- Detects pupil and highlight elements via gradient patterns and dark fills
- Wraps pupil+highlight elements in animated <g> groups
- Applies CSS keyframe animation for gentle wandering eye movement
- Uses different delays for left/right eyes for natural feel
- Only animates when awake (skips sleeping state)

Integrates animation into both BlobbiBabyVisual and BlobbiAdultVisual
components for a more lifelike appearance.
2026-03-16 21:23:24 -03:00
filemon ddf50724f0 fix: adult form resolution now uses adult_type tag as primary source
- Add adultType field to BlobbiCompanion interface
- Parse adult_type tag in parseBlobbiEvent from kind 31124
- Pass adult.evolutionForm in toBlobbiForVisual adapter
- Seed-derived form is now only used as fallback when no adult_type tag exists
2026-03-16 20:07:36 -03:00
filemon d07bd75d07 feat: implement adult Blobbi visual system with 16 evolution forms
- Add adult-blobbi module with types, SVG resolver, and customizer
- Support 16 adult forms: bloomi, breezy, cacti, catti, cloudi, crysti,
  droppi, flammi, froggi, leafy, mushie, owli, pandi, rocky, rosey, starri
- Each form has base and sleeping SVG variants
- Adult form resolved from blobbi.adult.evolutionForm or derived from seed
- Color customization applies to body and pupil gradients via pattern matching
- BlobbiAdultVisual component with reaction animations support
- Replace adult placeholder in BlobbiStageVisual with real visuals
2026-03-16 19:48:43 -03:00
filemon d59ba03cc6 fix: sing reaction only starts when recording begins, adjust animation timing
- Add onRecordingStart/onRecordingStop callbacks to InlineSingCard
- Move singing reaction trigger from card open to actual recording start
- Reduce sing bounce animation movement (6px → 3px for baby, 4px → 2px for egg)
- Slow down sing bounce animation (0.4s → 0.5s for baby, 0.5s → 0.6s for egg)
- Change Record button label to Sing
2026-03-16 19:29:50 -03:00
filemon 04112110f7 polish: faster music animation timing and fix egg centering with music notes 2026-03-16 19:22:40 -03:00
filemon d835cb5e6a feat: add floating music notes and tune animation timing for Blobbi reactions 2026-03-16 19:10:20 -03:00
filemon 6e5a6b5d91 feat: reusable music/dance reaction system for all Blobbi stages
BREAKING: Egg no longer sways by default when animated=true.
Sway animation now requires explicit reaction state.

Changes:
- Remove default egg sway from animated prop in EggGraphic
- Add reaction prop to EggGraphic, BlobbiEggVisual, BlobbiBabyVisual, BlobbiStageVisual
- Add EggReactionState, BabyReactionState, BlobbiReaction types
- Wire blobbiReaction state from BlobbiPage to BlobbiStageVisual
- Add animate-blobbi-sway and animate-blobbi-bounce CSS animations
- Add animate-egg-bounce for singing reaction on eggs

Reaction states:
- idle: no animation (default)
- listening: gentle sway (music playing)
- swaying: gentle sway
- singing: bouncy animation (sing action active)
- happy: gentle sway

Trigger behavior:
- Play Music: listening reaction when audio plays
- Sing: singing reaction when card opens
- Close activity: returns to idle
2026-03-16 18:48:57 -03:00
filemon 270cb51acc fix: volume control popup now renders via portal to avoid clipping
The volume slider was being clipped by the card's overflow:hidden.
Now using Popover component (Radix UI) which renders via portal,
ensuring the volume control appears above all UI elements correctly.
2026-03-16 18:31:02 -03:00
filemon 579c78b2ad feat: add volume control to InlineMusicPlayer
- Add volume state and setVolume function to useAudioPlayback hook
- Default volume is 0.8, persists across track changes
- Volume button shows Volume2 icon (or VolumeX when muted)
- Clicking volume button shows horizontal slider popup
- Slider controls real audio volume (0-1 range)
- Click outside to dismiss volume slider
2026-03-16 18:23:39 -03:00
filemon 470cdd1c76 refactor: change stop button to restart button in InlineMusicPlayer
- Add restart() function to useAudioPlayback hook (sets currentTime=0 and plays)
- Replace Square icon with RotateCcw for restart semantics
- Restart button resets track to beginning and continues playing
- Stop function still exists for cleanup on close
2026-03-16 18:04:31 -03:00
filemon 2abbae38d9 fix: stop button now truly stops playback instead of auto-restarting
The auto-start effect was incorrectly triggering on 'stopped' state,
causing immediate restart. Now 'stopped' is a terminal state that
requires explicit play button click to restart.
2026-03-16 18:00:43 -03:00
filemon 46dc8d9e12 fix: inline music player stop/track-switch behavior and UI cleanup
- Fix stop button: add 'stopped' state so stop truly stops playback instead of pausing at 0
- Fix track switching: detect source.url changes and reload, distinguish change vs initial selection
- Disable Upload tab in PlayMusicModal (marked 'Soon' but was still clickable)
- Remove misplaced chevron arrows from lyrics toggle button in InlineSingCard
2026-03-16 17:56:28 -03:00
filemon 59cbb9d740 fix: inline activity layout and move audio assets to public/
- Move inline activity cards inside padded container to prevent overlap with fixed bottom bar
- Move audio files from src/blobbi/audio/ to public/blobbi/audio/ for correct Vite asset loading
- Update track metadata with accurate durations from ffprobe
- Update documentation comments to reflect correct asset location
- Remove unused MicOff import from InlineSingCard
2026-03-16 17:38:21 -03:00
filemon c50c9bec7e refactor: inline activity cards for Play Music and Sing
- Add InlineMusicPlayer component for persistent music playback UI
- Add InlineSingCard component for inline recording/lyrics experience
- Add useAudioPlayback hook for reusable audio playback logic
- Add blobbi-activity-state types for activity and reaction state management

Play Music flow:
- PlayMusicModal now serves as track picker only
- After track selection, inline player appears with play/pause/stop controls
- Action published first, playback starts only after success

Sing flow:
- Recording happens inline (no modal)
- Lyrics panel expands upward with random lyrics
- Action published only when user confirms with 'Sing for Blobbi'

Blobbi reaction state prepared for future visual animations
2026-03-16 16:21:00 -03:00
filemon 14ebcb1165 Merge branch 'main' into feat-blobbi 2026-03-16 15:53:56 -03:00
filemon 3728ad02e6 fix: CSP and audio recording/playback for SingModal
- Add blob: to media-src CSP directive to allow recorded audio playback
- Add robust MIME type selection helper for MediaRecorder
- Try MIME types in order: webm;opus, webm, mp4, ogg;opus, ogg
- Track actual recorder MIME type and use it for blob creation
- Add user-friendly playback error messages (non-fatal amber warnings)
- Verify PlayMusicModal blob URL handling works correctly
- Add 'Soon' badge to Upload tab in PlayMusicModal
2026-03-16 15:50:22 -03:00
filemon ba9e1f5375 fix: cleanup pass for Blobbi play_music and sing actions
- Relax egg-stage blocking in canUseAction() (UI visibility vs domain logic)
- Fix egg inventory filtering to only show items with egg-compatible effects
- Remove 'shell' wording from medicine UI text
- Fix canonical data usage in mutations (use canonical.companion for decay)
- Fix browser timer typing (NodeJS.Timeout -> ReturnType<typeof setInterval>)
- Fix PlayMusicModal audio source switching (recreate Audio on source change)
- Fix SingModal recording playback (track current playback URL)
- Add random lyrics helper for Sing action with collapsible UI
2026-03-16 15:32:09 -03:00
filemon 598c5f90ea Add play_music and sing actions, fix egg clean/medicine consistency
- Fix egg stage actions: clean and medicine now work for eggs
- Add play_music action with built-in tracks and file upload
- Add sing action with in-browser audio recording
- Hide feed/play/sleep actions for eggs in UI (not hard-blocked)
- Both new actions increase happiness only (+15/+20)
- Placeholder built-in tracks in blobbi-builtin-tracks.ts
2026-03-16 15:08:30 -03:00
filemon 6e1a195615 Deprecate egg_temperature - eggs now use warmth prop fallback 2026-03-16 11:11:34 -03:00
filemon 452848f14f Remove shell_integrity - eggs now use standard health stat
BREAKING: shell_integrity is fully removed from the egg model.
Eggs now use the standard 3-stat model: health, hygiene, happiness.

Changes:
- Remove EggStats, EggMedicineResult types from blobbi-action-utils.ts
- Remove applyMedicineToEgg function (medicine now uses applyStat directly)
- Update useBlobbiUseInventoryItem to apply medicine health effect to egg health
- Update BlobbiActionInventoryModal to preview health changes for egg medicine
- Remove shell_integrity from ItemEffect in shop types
- Remove shellIntegrity from BlobbiEggData in types/blobbi.ts
- Remove EggStats, EggMedicineResult, applyMedicineToEgg from exports
- Add DEPRECATED_BLOBBI_TAG_NAMES set with 'shell_integrity'
- Update mergeBlobbiStateTagsForRepublish to filter out deprecated tags

Migration: Existing events with shell_integrity tags will have them
automatically removed on the next republish (any user interaction).

Egg stat model is now fully consistent:
- health, hygiene, happiness: active (decay + medicine)
- hunger, energy: fixed at 100
2026-03-16 00:20:33 -03:00
filemon 4ccc123209 Apply decay before stage transitions (hatch/evolve)
- Create useBlobbiHatch hook for egg -> baby transition
- Create useBlobbiEvolve hook for baby -> adult transition
- Both hooks apply accumulated decay before publishing new state
- Wire up floating action button to trigger hatch/evolve based on stage
- Hide evolve button for adults (already fully evolved)
- Show loading state during transitions
- Export new hooks and types from blobbi/actions module

Stage transitions now consistently apply decay first, ensuring
no transition can happen from stale stats.
2026-03-15 21:10:44 -03:00
filemon a987449789 Apply decay before sleep/wake mutations in handleRest
- Import applyBlobbiDecay in BlobbiPage
- Calculate accumulated decay before state change
- Persist decayed stats along with new sleep/wake state
- Reset last_decay_at timestamp after applying decay

This ensures stats accurately reflect elapsed time when toggling
between active and sleeping states.
2026-03-15 21:04:36 -03:00
filemon ba9ff0964b feat(blobbi): implement decay system with projected UI state
Core decay system (src/lib/blobbi-decay.ts):
- Pure applyBlobbiDecay() function for deterministic decay calculation
- Stage-specific decay rates: egg (2-3hr), baby (3-5hr), adult (5-7hr)
- Health modifiers based on other stats
- Health regeneration when all stats >= 80
- Floor all deltas, clamp stats to 0-100
- Warning/critical threshold helpers

UI projection hook (src/hooks/useProjectedBlobbiState.ts):
- Calculates projected stats without publishing
- Recalculates every 60 seconds
- Returns visible stats with status indicators

BlobbiPage updates:
- Uses projected state for display
- Egg shows 3 stats (health, hygiene, happiness)
- Baby/adult shows all 5 stats
- StatIndicator supports warning/critical status styling

Mutation updates (useBlobbiUseInventoryItem):
- Applies accumulated decay before interactions
- Uses decayed stats as base for item effects
- Updates last_decay_at on every interaction

Documentation (docs/blobbi/decay-system.md):
- Comprehensive explanation of the system
- All decay rates and thresholds
- Mutation flow diagram
- Edge cases and assumptions
2026-03-15 21:01:34 -03:00
filemon 3cd64fb0af refactor(blobbi): make evolve button icon stage-aware
- egg stage: Shows Egg icon with 'Hatch' tooltip
- baby/adult stages: Shows Sparkles icon with 'Evolve' tooltip

Implementation:
- Added 'stage' prop to BlobbiDashboardFloatingControlsProps
- Created getEvolveIcon() helper - returns Egg or Sparkles based on stage
- Created getEvolveTooltip() helper - returns 'Hatch' or 'Evolve'
- Removed unused Zap import

Icon choice rationale:
- Sparkles was chosen for non-egg stages because it communicates magical
  transformation, which fits the Blobbi fantasy/pet theme better than
  technical icons like TrendingUp or ArrowUpCircle
2026-03-15 19:48:43 -03:00
filemon 6e8e6fe243 feat(blobbi): add floating action buttons to dashboard (placeholders)
New components:
- FloatingActionDef: Typed interface for button definitions
- BlobbiDashboardFloatingControls: Component that renders left and right
  floating button clusters

Buttons added (all visual-only placeholders):
- Right side (top cluster):
  - Settings (Settings icon)
  - Set as Companion (Heart icon)
  - Take a Photo (Camera icon)
  - Open PiP (PictureInPicture2 icon)
  - Blobbi Info (Info icon) - wired to existing info modal
  - Evolve (Zap icon) - styled with accent/primary colors
- Left side:
  - Back button (ArrowLeft icon) - optional, not rendered by default

Implementation:
- Uses existing QuickActionButton for consistent styling
- Evolve button has distinct accent styling (primary colors)
- Button definitions centralized in typed arrays
- Placeholder handlers use console.log('TODO: ...')
- Existing info modal functionality preserved
2026-03-15 19:37:35 -03:00
filemon 476f1bade2 feat(blobbi): apply base color to dashboard and info modal names
- BlobbiDashboard: The h2 element displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- BlobbiInfoModal: The DialogTitle displaying companion.name now uses
  style={{ color: companion.visualTraits.baseColor }}
- Both update correctly when selecting a different Blobbi
- Selector card names left unchanged (not prominent display)
2026-03-15 19:29:39 -03:00
filemon cf1d9ad53f fix(blobbi): add separate colored name display above egg
- Removed baseColor styling from Input (was not working reliably)
- Added a separate <p> element between the input and egg visual
- The <p> displays trimmedName with style={{ color: preview.visualTraits.baseColor }}
- This element updates live as the user types (uses trimmedName from preview.name)
- Input reverted to normal theme styling (text-center font-medium)
- No duplicate name under the egg (title still not passed to EggGraphic)
2026-03-15 19:22:10 -03:00
filemon 680ff86202 fix(blobbi): apply egg base color to name input field
- Removed the extra styled name display div (was creating a second name)
- Applied base color directly to the Input element via style prop
- Changed Input className to 'text-center font-semibold text-lg' for better visibility
- Now only ONE visible name exists: the input field itself, styled with the egg's baseColor
2026-03-15 19:19:06 -03:00
filemon 9797fcd95a fix(blobbi): canonicalize patterns and fix duplicate name display
Pattern canonicalization:
- Changed VALID_PATTERNS from ['gradient', 'solid', 'speckled', 'striped']
  to ['solid', 'spotted', 'striped', 'gradient'] to match domain model
- 'spotted' is the canonical value (used by BlobbiPattern type, BLOBBI_PATTERNS,
  derivePatternFromSeed, normalizePatternTag, and PATTERN_MAP)

Duplicate name fix:
- Removed title from toEggGraphicVisualBlobbi() adapter - the EggGraphic
  'title' field is for special designations (e.g., 'Divine'), not pet names
- The duplicate was: input field above egg + title display below egg
- Now only the input field exists, plus a new styled name display

Name styling:
- Added styled name display above the egg using the egg's baseColor
- Styling matches the former bottom title: bg-black/20, backdrop-blur-sm,
  font-semibold, text-shadow, and color from preview.visualTraits.baseColor
2026-03-15 19:14:52 -03:00
filemon 5d3841d6a7 fix(blobbi): cleanup egg validation inconsistencies
- Fix getColorRarity() to properly merge both base color palettes by
  creating MERGED_BASE_COLORS_BY_RARITY that combines colors per rarity
  tier (previous spread syntax overwrote keys instead of merging arrays)
- Update validation error messages to match actual validation logic:
  colors now accept any valid hex format, not just specification palettes
- Add Rarity type for better type safety in rarity functions
- Add JSDoc clarifying that getColorRarity returns null for domain model
  colors (BLOBBI_BASE_COLORS) which are not in the legacy palettes
2026-03-15 18:39:10 -03:00
filemon 9e5de53ad4 fix(blobbi): make egg colors update on reroll
Root cause: EggGraphic validation rejected derived colors because
isValidBaseColor() used a hardcoded allowlist that didn't include
the BLOBBI_BASE_COLORS palette (e.g., #F59E0B, #55C4A2, etc.).

Changes:
- Update isValidBaseColor/isValidSecondaryColor to accept any valid
  hex color format (palette enforcement at domain level)
- Add visual trait tags (base_color, secondary_color, eye_color,
  pattern, special_mark, size) to previewToEventTags for
  deterministic rendering
- Improve useMemo dependencies in BlobbiEggVisual to ensure
  re-render on preview change
2026-03-15 13:59:36 -03:00
filemon 13cdbc565c feat(blobbi): add 'Adopt another Blobbi' entry point from selector
Adds a dedicated CTA card in the Blobbi selector modal and page to allow
existing users to adopt additional Blobbies without going through full
onboarding.

Changes:
- Added AdoptAnotherBlobbiCard component with plus icon, tooltip, and
  distinct visual styling (dashed border, centered layout)
- Updated BlobbiOnboardingFlow to support adoptionOnly prop that skips
  profile creation and adoption question, going directly to egg preview
- Updated useBlobbiOnboarding hook with adoptionOnly mode support that:
  - Derives initial step as 'preview' when adoptionOnly is true
  - Generates preview immediately on mount in adoptionOnly mode
  - Skips auto-sync logic that would interfere with explicit control
- Added adoption flow modal to BlobbiDashboard with full callback wiring
- Added adoption flow modal to BlobbiSelectorPage (Cases G and H)
- Passed required adoption callbacks through BlobbiDashboard props

UX flow:
1. User clicks 'Adopt another Blobbi' card in selector
2. Selector closes, adoption flow modal opens
3. User sees egg preview directly (no profile/adoption question steps)
4. User can reroll, name, and adopt as normal
5. On completion, modal closes and new Blobbi is selected
2026-03-15 13:44:48 -03:00
filemon 5ea4b0f73d fix(blobbi): add pettingLevel, fix reroll visual, improve onboarding stability
1. pettingLevel support:
   - Added pettingLevel to BlobbonautProfile type and parsing
   - New profiles include pettingLevel: 0 by default
   - Created useBlobbonautProfileNormalization hook to auto-add
     pettingLevel to existing profiles that are missing it

2. Reroll visual fix:
   - Added key prop to BlobbiStageVisual to force remount on preview change
   - Added debug logging to track preview identity changes (d/seed/petId)
   - Reroll preserves typed name while generating new identity

3. Onboarding stability improvements:
   - Enhanced step sync logic in useBlobbiOnboarding to handle all edge cases
   - Added defensive checks for profile state changes
   - Better debug logging for state transitions

4. Verified invariants:
   - Preview remains single source of truth for adopted event
   - Name is editable, required for adoption, preserved on reroll
   - No any types introduced
2026-03-15 13:16:26 -03:00
filemon f3e262bd3a fix(blobbi): respect user profile state in onboarding flow
- Fixed useBlobbiOnboarding to derive initial step from profile state
- Added useEffect to sync step when profile loads from cache/relay
- Added egg name customization via updatePreviewName() function
- Removed 'Maybe Later' skip option from adoption step
- Refactored BlobbiPage with cleaner state logic and debug logging
- Fixed TypeScript errors (unused vars, empty interface)

Resolves issue where onboarding always started on 'profile' step
even when user already had a profile, and adds name input to
egg preview before adoption.
2026-03-15 12:48:58 -03:00
filemon 96b8288c5b feat(blobbi): implement new onboarding flow with egg preview
Add complete Blobbi onboarding flow:
- Profile creation step with name prefill from kind 0 metadata
- Adoption question step after profile creation
- Egg preview with reroll (10 coins) and adopt (100 coins) options
- Confirmation dialog before adoption
- New profiles start with 200 coins

Key components:
- BlobbiProfileOnboarding: Profile creation with name input
- BlobbiAdoptionStep: 'Ready to adopt?' prompt
- BlobbiEggPreviewCard: Egg preview with visual traits and actions
- BlobbiAdoptionConfirmDialog: Adoption cost confirmation
- useBlobbiOnboarding: State and action orchestration hook

Preview is the source of truth for adoption - same exact data is
used to create the final kind 31124 event. Coins are deducted
from profile before publishing events.
2026-03-14 16:44:14 -03:00
filemon a85590f5fb Merge branch 'main' into feat-blobbi 2026-03-13 23:16:16 -03:00
filemon 5a93bdd0a6 Merge branch 'main' into feat-blobbi 2026-03-10 10:14:43 -03:00
filemon be582f4db7 fix: use migrated profile context in inventory item usage flow
Bug: When using Feed/Play/Clean on a legacy Blobbi, the migration
would correctly publish a canonical profile, but then the inventory
usage flow would republish the profile using stale pre-migration tags
from the hook closure, restoring legacy has/current_companion values.

Fix:
- Extend EnsureCanonicalResult to include profileAllTags and profileStorage
- Extend MigrationResult to include profileTags and profileStorage
- Update ensureCanonicalBlobbiBeforeAction to return profile context
- Update useBlobbiUseInventoryItem to use canonical.profileStorage and
  canonical.profileAllTags instead of profile.storage/profile.allTags

This ensures the post-item-use 31125 event is built from the migrated
profile state, preserving:
- canonical has[] values
- canonical current_companion
- storage changes (item decrement)
- all unknown tags
2026-03-09 17:39:53 -03:00
filemon 01a174f9e3 feat: add Medicine action with egg-specific shell_integrity support
- Add 'medicine' to InventoryAction type and related mappings
- Medicine is available for all stages: egg, baby, adult
- For eggs: health effect is converted to shell_integrity
- For eggs: other effects (energy, happiness, etc.) are ignored
- For baby/adult: all effects are applied normally

Egg-specific behavior:
- previewMedicineForEgg() shows shell_integrity changes
- applyMedicineToEgg() converts health → shell_integrity
- hasMedicineEffectForEgg() validates egg-applicable effects

Stage restriction changes:
- canUseAction(companion, action) replaces canUseInventoryItems()
- EGG_ALLOWED_ACTIONS defines which actions eggs can use
- getStageRestrictionMessage() now action-aware

UI updates:
- Medicine button added to BlobbiActionsModal (Pill icon)
- Inventory modal shows shell_integrity preview for eggs
- Contextual description: 'Strengthen your egg's shell' for eggs
2026-03-09 17:25:51 -03:00
filemon ecc306079b feat: implement inventory item usage with Feed, Play, Clean actions
- Add blobbi-action-utils.ts with stat clamping, item effects, and inventory filtering
- Create useBlobbiUseInventoryItem hook for consuming inventory items
- Add BlobbiActionInventoryModal for selecting items to use per action type
- Update BlobbiActionsModal with Feed, Play, Clean, Sleep/Wake buttons
- Integrate action modals with BlobbiPage
- Remove duplicate StorageItem from shop.types.ts (kept in lib/blobbi.ts)
- Stage restrictions: eggs cannot use items, only baby/adult can

The inventory usage flow:
1. User opens Actions modal from bottom bar
2. Selects Feed/Play/Clean to open inventory modal
3. Modal shows filtered items by action type with effect preview
4. On item use: updates Blobbi stats (31124) and decrements storage (31125)
2026-03-09 17:12:49 -03:00
filemon 251ea43e33 refactor: reorganize Blobbi shop into domain-scoped structure with list layout
- Move shop module to src/blobbi/shop/ for clear domain boundaries
- Rename components with Blobbi prefix for clarity:
  - ShopModal → BlobbiShopModal
  - InventoryModal → BlobbiInventoryModal
  - PurchaseDialog → BlobbiPurchaseDialog
  - usePurchaseItem → useBlobbiPurchaseItem
  - shop-items.ts → blobbi-shop-items.ts
  - shop.ts → shop.types.ts

- Convert shop UI from card grid to vertical list layout:
  - Replace ShopItemCard with BlobbiShopItemRow
  - Horizontal row layout: icon, name, category, effects, price, button
  - More compact and scannable design
  - Better mobile responsiveness
  - Easier to compare items at a glance

- Add blobbi-shop-utils.ts with helper functions:
  - formatEffectSummary() for compact effect display
  - getEffectCounts() for stat summaries
  - getPrimaryEffect() for tooltips/badges

- Folder structure:
  src/blobbi/shop/
    components/     (UI components)
    hooks/          (purchase hook)
    lib/            (catalog + utils)
    types/          (TypeScript types)

- Update all imports in BlobbiPage to use new paths
- Remove old generic shop paths (src/components/shop, etc.)
- Preserve all existing behavior and purchase flow
- No Lightning/sats/kind 40100/40101 (as specified)
- TypeScript strict, no any types used
2026-03-09 15:48:31 -03:00
filemon 37b8fc6752 feat: implement Blobbi Shop and Inventory system
- Add shop item catalog with 24 items (food, toys, medicine, hygiene, accessories)
- Create shop types: ShopItem, StorageItem, ItemEffect, PurchaseRequest
- Extend BlobbonautProfile with coins and storage fields
- Add storage tag parsing/serialization helpers for kind 31125
- Implement usePurchaseItem hook for atomic coin + storage updates
- Create ShopModal with category tabs and item grid
- Create PurchaseDialog with quantity selector and affordability checks
- Create InventoryModal to display purchased items
- Integrate shop and inventory into BlobbiPage footer
- Purchase flow: validates price, checks coins, stacks items, publishes single event
- Accessories marked as 'Coming Soon' (disabled state)
- No Lightning/LNURL/sats purchase flow (as specified)
- Only uses kind 31125 for profile persistence (no kind 40100/40101)
2026-03-09 15:32:03 -03:00
filemon 17b986c21d fix: use baseColor as secondaryColor fallback for legacy events without seed
When a legacy Blobbi has only base_color (no secondary_color, no seed),
the secondary color now falls back to the resolved baseColor instead of
the generic yellow default (#FCD34D).

This creates a unified palette for legacy events with partial traits,
avoiding the incorrect mixed palette (e.g., cyan base + yellow accent).
2026-03-09 13:04:51 -03:00
filemon d6b3dbc9f9 fix: respect explicit visual traits in legacy Blobbi events
The deriveVisualTraits function was returning default values immediately
when no seed was present, ignoring explicit tags in the event.

Fixed priority order (per field):
1. Explicit valid tags (always take precedence)
2. Seed-derived values (for canonical events)
3. Default fallbacks (when both are missing)

This ensures legacy Blobbi with explicit color/pattern tags render
correctly instead of falling back to default yellow/orange palette.
2026-03-09 12:51:56 -03:00
filemon 1f41478d53 wire baby rendering into the app with stage-aware visual component
- Add BlobbiBabyVisual component using baby-blobbi module for SVG resolution and customization
- Add BlobbiStageVisual component that routes rendering by life stage (egg/baby/adult)
- Update BlobbiPage to use BlobbiStageVisual for all Blobbi displays
- Uncomment BlobbiBabyData interface in types/blobbi.ts to fix type error
2026-03-09 12:27:16 -03:00
filemon bd71520cb5 add baby-blobbi file 2026-03-09 12:22:44 -03:00
filemon 6276d135e4 Merge branch 'main' into feat-blobbi 2026-03-08 10:33:58 -03:00
filemon 3abdbc2d88 cleanup: remove visibility feature, info card, and polish bottom bar spacing
Removed features:
- handleToggleVisibility handler and all related props
- Visibility quick action button from top-right floating actions
- Visibility action from BlobbiActionsModal
- Info card section (Generation, Experience, Care Streak, Last Active)

Removed unused code:
- InfoItem component
- formatTimeAgo helper function
- Card/CardContent imports

Bottom bar spacing adjustments:
- Container padding: px-2 → px-3
- Grid gap: gap-1 → gap-2
- Group internal gap: gap-0.5 → gap-1
- Center button margin: mx-1 → mx-2
- Button padding: px-2.5 → px-3
- Button min-width: 52px → 56px

Result: Cleaner dashboard with more breathing room in the bottom bar
2026-03-06 23:46:18 -03:00
filemon 241f234a82 polish: refine bottom bar layout and change center icon to Sparkles
Layout changes:
- Switch from flex justify-between to 3-column grid layout
- Left group now uses justify-end (closer to center)
- Right group now uses justify-start (closer to center)
- Reduce group gap from gap-1 to gap-0.5
- Reduce container padding from px-3 to px-2

Center button adjustments:
- Reduce vertical offset from -mt-6 to -mt-4 (more integrated)
- Reduce size from size-14 to size-12
- Add mx-1 for controlled horizontal spacing
- Replace Zap icon with Sparkles (better fits Blobbi identity)
- Reduce icon size from size-6 to size-5

Side button adjustments:
- Reduce horizontal padding from px-3 to px-2.5
- Reduce vertical padding from py-2 to py-1.5
- Reduce min-width from 60px to 52px

Result: More compact, balanced bottom bar with groups visually
closer to the center action button
2026-03-06 23:39:44 -03:00
filemon 28ee5b6881 feat: add bottom action bar and modal system to BlobbiPage
UI adjustments:
- Move top-right floating buttons lower for visual balance
- Remove switch button from top-right (now in bottom bar)
- Add fixed bottom action bar with left/center/right layout:
  - Left: Blobbies (opens selector), Missions (placeholder)
  - Center: Actions button (opens actions modal)
  - Right: Shop (placeholder), Inventory (placeholder)

New components:
- BlobbiBottomBar: Fixed bottom navigation bar
- BlobbiActionsModal: Rest/Wake and Hide/Show actions
- BlobbiPlaceholderModal: Reusable placeholder for future features
- BlobbiInfoModal: Detailed Blobbi information display
- BottomBarButton: Reusable button for bottom bar

Behavior:
- Blobbies button opens existing selector modal
- Actions modal contains functional Rest and Visibility toggles
- Info button (top-right) opens detailed info modal
- Placeholder modals ready for Missions, Shop, Inventory features
2026-03-06 23:34:21 -03:00
filemon d92790f0af refactor: replace hardcoded colors with app theme tokens in BlobbiPage
- Remove purple/pink gradients and hardcoded colors
- Use theme tokens: primary, muted, accent, border, card, background
- Keep semantic stat colors (orange/yellow/green/blue/violet) for meaning
- Page chrome now adapts to light/dark theme automatically
2026-03-06 23:22:46 -03:00
filemon e18d0592d6 refactor: redesign BlobbiPage with dashboard-style layout
Visual changes:
- Add frosted glass dashboard container with purple top border
- Add decorative purple/pink gradient overlay
- Center hero layout with prominent Blobbi visual
- Add floating quick action buttons (switch, rest, visibility)
- Replace stat bars with circular stat indicators
- Improve visual hierarchy with hero section focus
- Add glow effect behind main Blobbi visual
- Use purple/pink color theme consistently

Extracted components:
- DashboardShell: Frosted glass container wrapper
- BlobbiDashboard: Main dashboard view with hero section
- QuickActionButton: Floating circular action buttons
- StatIndicator: Circular progress stat display
- DashboardLoadingState: Skeleton loading for dashboard

Preserved unchanged:
- All data hooks (useBlobbonautProfile, useBlobbisCollection, etc.)
- Selection flow with localStorage priority
- Migration flow with ensureCanonicalBlobbiBeforeAction
- Create egg / rest / visibility handlers
- EggGraphic adapter integration
- Selector page and card components (restyled)
2026-03-06 23:16:35 -03:00
filemon 11802fc38a refactor: tighten all adapter types to exact Egg module unions
- Add EggPattern, EggSpecialMark, EggThemeVariant type aliases
- All derived via NonNullable<EggVisualBlobbi[field]>
- Update PATTERN_MAP to Record<BlobbiPattern, EggPattern>
- Update SPECIAL_MARK_MAP to Record<BlobbiSpecialMark, EggSpecialMark>
- Update all fallback constants with exact Egg types
- Update themeVariant parameter to EggThemeVariant
- No runtime changes, type-only refinement
2026-03-06 23:08:04 -03:00
filemon b2ae35d597 refactor: tighten adapter typing for EggVisualBlobbi
- Derive EggLifeStage type from EggVisualBlobbi using NonNullable
- Type LIFE_STAGE_MAP with exact EggLifeStage return type
- Add DEFAULT_THEME_VARIANT constant for consistency
- Remove unnecessary 'as const' assertions on mapping objects
- Improve JSDoc for toEggGraphicVisualBlobbi return type
- Rename areEggGraphicVisualsEqual parameter types to EggVisualBlobbi
2026-03-06 23:04:25 -03:00
filemon ec37a8befe refactor: cleanup Blobbi egg integration
- Remove fake try/catch render wrapper (not a real error boundary)
- Use real EggVisualBlobbi type from @/blobbi/egg module
- Pass full allTags to EggGraphic instead of filtering
- Adjust size mapping: sm (size-14/small), md (size-24/medium), lg (size-40/large)
- Gate debug logs behind import.meta.env.DEV check
- Simplify adapter by removing duplicated type definitions
- Reduce adapter from 290 lines to ~150 lines
- Keep architecture intact: domain → adapter → BlobbiEggVisual → EggGraphic
2026-03-06 21:24:58 -03:00
filemon 804dd550a2 feat(blobbi): add egg visual module 2026-03-06 21:10:29 -03:00
filemon 736d76f457 feat: integrate EggGraphic visual module into Blobbi UI
- Create BlobbiEggVisual reusable component in src/blobbi/ui/
- Replace placeholder Egg icons with real EggGraphic rendering
- Update adapter tags format to string[][] for EggGraphic compatibility
- Main display: large animated egg visual with seed-derived colors
- Selector cards: small egg visual showing unique traits per Blobbi
- Switch dialog: uses same selector cards with real visuals
- Add memoization for adapter output to avoid re-renders
- Include fallback safety with simple placeholder on render errors
- Preserve all existing fetch/migration/selection behavior
2026-03-06 21:06:01 -03:00
filemon 84734d7304 feat: add EggGraphic adapter and improve visual trait derivation
- Create blobbi-egg-adapter.ts with toEggGraphicVisualBlobbi() function
- Add explicit mapping tables for pattern, specialMark, size, lifeStage
- Add comprehensive VISUAL TRAIT POLICY documentation
- Export LEGACY_VISUAL_TAG_NAMES constant for migration preservation
- Improve deriveNameFromLegacyD() to handle multi-word names (mr-cool → Mr Cool)
- Update buildMigrationTags() to explicitly preserve legacy visual tags
- Add capitalizeWords() helper for human-friendly name formatting
- Add areEggGraphicVisualsEqual() utility for memoization support
2026-03-06 20:09:46 -03:00
filemon e19b99a2bc refactor: align visual traits with EggGraphic contract
Visual Traits Interface:
- baseColor: hex value (e.g., '#F59E0B')
- secondaryColor: hex value for accent
- eyeColor: hex value for eyes
- pattern: 'solid' | 'spotted' | 'striped' | 'gradient'
- specialMark: 'none' | 'star' | 'heart' | 'sparkle' | 'blush'
- size: 'small' | 'medium' | 'large'

Color Palettes (10 colors each):
- BLOBBI_BASE_COLORS: Amber, Teal, Sky Blue, Pink, Purple, Coral, Emerald, Yellow, Indigo, Orange
- BLOBBI_SECONDARY_COLORS: Light variants for accents
- BLOBBI_EYE_COLORS: 8 expressive options

Seed Derivation:
- deriveIndexFromSeed with documented offset layout
- Each trait derived from different seed segment
- Validation/normalization for legacy tags
- Falls back to DEFAULT_VISUAL_TRAITS when seed missing

Debug Logs:
- Concise format: d, name, isLegacy, hasSeed, traits summary
- Removed verbose visualTraits object dump

parseBlobbiEvent remains single source of truth for:
- name resolution (tag > legacy d > fallback)
- seed
- visualTraits
- isLegacy flag

Migration flow unchanged - ready for EggGraphic integration.
2026-03-06 14:45:58 -03:00
filemon 577334cbaa feat: add visual trait derivation from seed and centralized migration
PART 1 - Visual Traits from Seed:
- Add BlobbiVisualTraits interface with baseColor, pattern, specialMark, size
- Add deriveNumberFromSeed helper for deterministic derivation
- Add deriveBaseColorFromSeed, derivePatternFromSeed, deriveSpecialMarkFromSeed, deriveSizeFromSeed
- Add deriveVisualTraits to combine tag values with seed-derived fallbacks
- Visual trait priority: explicit tags > derived from seed > default values

PART 2 - Legacy Event Detection:
- Add isLegacyBlobbiEvent helper function
- Legacy criteria: non-canonical d-tag, missing seed, missing name tag, visual traits without seed
- Add companionNeedsMigration convenience wrapper
- Add isLegacy and visualTraits fields to BlobbiCompanion interface

PART 3-7 - Centralized Migration:
- Create useBlobbiMigration hook with ensureCanonicalBlobbiBeforeAction
- Migration auto-updates: localStorage selection, React Query caches, profile references
- Refactor BlobbiPage action handlers to use centralized helper
- Migration continues original action after upgrading legacy pet

No changes to event kinds, collection fetching, or selection system.
2026-03-06 14:27:18 -03:00
filemon 35b615dc9f fix: support legacy Blobbi names derived from d-tag
- Add deriveNameFromLegacyD() helper to extract name from legacy d-tags
- Update parseBlobbiEvent to use name resolution priority:
  1. Use 'name' tag if present
  2. Derive from legacy d-tag format (blobbi-{name} → Name)
  3. Fall back to 'Unnamed Blobbi'
- Add debug logs in parser showing d, name, nameTag, stage, state
- Add '[Blobbi UI]' debug log when selected companion changes
- Legacy pets like 'blobbi-puck' now display as 'Puck'
2026-03-06 14:13:40 -03:00
filemon ef8e9a3ccf fix: load ALL Blobbis and implement proper UI selection flow
- Update useBlobbisCollection to fetch ALL pets without limit:1
- Add chunking support (20 items per chunk) for relay compatibility
- Add debug logs for dList and 31124 query filter
- Implement localStorage-based UI selection (user-scoped key)
- Selection priority: localStorage > first in profile.has > show selector
- Add BlobbiSelectorPage for when no valid selection exists
- Add BlobbiSelectorCard component for pet selection UI
- Add 'Switch Blobbi' button in header for users with multiple pets
- Separate concerns: currentCompanion (global) vs selectedBlobbi (page UI)
2026-03-06 13:45:51 -03:00
filemon 27d5544f8b Fetch all Blobbi pets (Kind 31124) by multiple d-tags
- Add useBlobbisCollection hook to query all d-tags from profile.has[] and currentCompanion
- Update BlobbiContent to use collection hook instead of single companion hook
- Keep only newest event per d-tag for deduplication
- Add debug logging to verify multi-d-tag REQs in DevTools
- UI still renders only the selected companion (currentCompanion or first in has[])
2026-03-06 13:25:49 -03:00
filemon e98111bf00 fix: useBlobbonautProfile type alignment and add effectiveCompanionD
- Fix BlobbiBootCache type usage (companion singular, not companions plural)
- Add effectiveCompanionD to hook return value for BlobbiPage
- Add debug logging to track kind 31125 query execution
- Add refetchOnMount: 'always' with initialDataUpdatedAt for proper cache behavior
2026-03-05 21:04:38 -03:00
filemon c8c68f1898 Refactor Blobbi system for spec compliance and fix loading issues
- Fix canonical Blobbi ID regex to require hex-only petId per spec
- Add getOrDeriveSeed helper to prevent seed recomputation
- Separate managed tag sets for Kind 31124 and 31125
- Add tag deduplication helpers (deduplicateHasTags)
- Add legacy pet migration on user interactions
- Fix localStorage cache validation with pubkey ownership check
- Add effectiveCompanionD fallback to has[] array
- Prevent premature "Create Egg" UI while queries resolve
- Add usePersistentNostrSession hook with exponential backoff
- Configure React Query to prevent refetch loops (staleTime, refetchOnWindowFocus)
2026-03-05 17:32:32 -03:00
filemon f47ccbec51 Merge branch 'main' into feat-blobbi 2026-03-05 17:11:28 -03:00
filemon ba53bc05a4 Add Blobbi egg feature with profile and companion management
Implements the initial Blobbi ecosystem (egg stage only) per the spec:
- Kind 31125 (Blobbonaut Profile) with canonical d-tag and legacy support
- Kind 31124 (Blobbi Current State) with canonical d-tag and seed derivation
- localStorage boot cache for instant UI on page load
- Profile initialization, egg creation, rest action, visibility toggle
- Preserves unknown tags when republishing for forward compatibility
2026-03-04 20:29:21 -03:00
585 changed files with 88202 additions and 8027 deletions
+112
View File
@@ -0,0 +1,112 @@
---
name: lockdown-mode
description: Apple Lockdown Mode restrictions and their impact on web APIs inside WKWebView/Safari/WebView. Reference when debugging or building features for lockdown-enabled devices.
---
# Apple Lockdown Mode
Apple's Lockdown Mode is an opt-in security hardening profile that disables or restricts many web platform APIs inside Safari and WKWebView. Since this app ships inside a Capacitor WKWebView shell, **every restriction that applies to Safari also applies to our app**.
## Platform Availability
Lockdown Mode is available on:
- **iOS 16** or later (iPhone)
- **iPadOS 16** or later (iPad)
- **watchOS 10** or later (Apple Watch)
- **macOS Ventura** or later (Mac)
Additional protections are available starting in iOS 17, iPadOS 17, watchOS 10, and macOS Sonoma.
For full details, see [About Lockdown Mode](https://support.apple.com/en-us/105120) on Apple Support.
## Testing Baseline
This document is based on testing against **iOS 18.7 / Safari 26.4** on an iPhone with Lockdown Mode enabled (April 2026). The web API restrictions documented below apply to Safari and WKWebView across all supported platforms (iOS, iPadOS, and macOS).
## Blocked APIs
These APIs are **completely unavailable** (return `undefined`, `null`, or throw) when Lockdown Mode is active.
| API | Impact | Notes |
|-----|--------|-------|
| **IndexedDB** | Critical | `indexedDB` global is missing entirely. Any library that relies on IndexedDB for storage will fail (Dexie, idb, localForage with IndexedDB driver, etc.). |
| **Service Workers** | High | `navigator.serviceWorker` is absent. No offline caching, no background sync, no push notifications via SW. |
| **Cache API** | High | `caches` global is absent. Often used alongside Service Workers for offline strategies. |
| **WebAssembly** | High | `WebAssembly` global is `undefined`. Libraries compiled to WASM (e.g. libsodium-wrappers, secp256k1-wasm, SQLite WASM) will not load. |
| **Web Locks** | High | `navigator.locks` is absent. Cross-tab coordination patterns that depend on this will break silently. |
| **WebRTC** | High | `RTCPeerConnection` is absent. No peer-to-peer audio/video/data channels. |
| **WebGL / WebGL2** | Medium | All canvas `getContext('webgl'*)` calls return `null`. GPU-accelerated rendering, maps (Mapbox GL, deck.gl), and 3D are broken. |
| **FileReader** | Medium | `FileReader` constructor is absent. Cannot read `Blob`/`File` objects client-side (e.g. image preview before upload). Use the `File` constructor + `URL.createObjectURL()` as a workaround for previews. |
| **SharedArrayBuffer** | Medium | `SharedArrayBuffer` is `undefined`. May also require COOP/COEP headers on non-lockdown browsers, so this is often already unavailable. |
| **Speech Synthesis** | Low | `window.speechSynthesis` is absent. Text-to-speech features won't work. |
| **Notifications API** | Low | `Notification` is absent. Web push permission prompts won't appear. (Capacitor local notifications via the native plugin are unaffected.) |
| **WebCodecs** | Low | `VideoDecoder` / `VideoEncoder` are absent (`AudioDecoder` remains). Low-level media processing is unavailable. |
| **Gamepad API** | Low | `navigator.getGamepads` is absent. |
| **OPFS** | Medium | `navigator.storage.getDirectory` method does not exist. The `navigator.storage` object is present but the Origin Private File System API is stripped. SQLite-over-OPFS and any other OPFS-based storage will fail. |
| **Web Share API** | Low | `navigator.share` is absent. Use Capacitor's `@capacitor/share` plugin instead -- the native share sheet still works. |
## Available APIs
These APIs **still work** under Lockdown Mode and can be relied on.
| API | Notes |
|-----|-------|
| **File constructor** | `new File(...)` works. You can create File/Blob objects. |
| **FontFace API** | Dynamic font loading via `new FontFace()` succeeds. Remote font fetches may fail with a network error (data URIs rejected). |
| **JIT compilation** | JavaScript JIT appears active (~110ms for 1M iterations). Performance is not interpreter-level degraded. |
| **PDF viewer** | `navigator.pdfViewerEnabled` is `true`. Inline `<embed type="application/pdf">` works. |
| **Cookies** | `navigator.cookieEnabled` is `true`. |
| **Credential Management** | `navigator.credentials` is available. |
| **localStorage / sessionStorage** | Standard Web Storage APIs remain functional. |
## Implications for This App
### Storage
- **localStorage works** -- our primary client-side storage (app config, relay lists, etc.) is unaffected.
- **IndexedDB is gone** -- if any dependency silently uses IndexedDB (e.g. some Nostr caching layers, TanStack Query persisters), it will fail. Ensure all storage paths fall back to localStorage or in-memory.
- **OPFS is gone** -- `navigator.storage.getDirectory` is stripped (the method doesn't exist, though the `navigator.storage` object itself remains). SQLite-over-OPFS (e.g. wa-sqlite, sql.js with OPFS backend) and any other OPFS-based persistence will not work.
### Cryptography
- **WebAssembly is blocked** -- any WASM-based crypto libraries (secp256k1 compiled to WASM, libsodium WASM builds) will not load. Use pure-JS implementations (e.g. `@noble/secp256k1`, `@noble/hashes`) which are already what nostr-tools uses.
- **WebCrypto (`crypto.subtle`)** -- not listed as blocked in testing. The SubtleCrypto API should still be available for NIP-44 encryption via the standard Web Crypto path.
### Media & Rendering
- **WebGL is gone** -- map libraries that require WebGL (Mapbox GL JS, Google Maps WebGL renderer) will show blank canvases. Use raster tile alternatives or static map images.
- **FileReader is gone** -- image/file preview workflows that use `FileReader.readAsDataURL()` need a workaround. Use `URL.createObjectURL(file)` directly for `<img src>` previews instead.
### Communication
- **WebRTC is gone** -- any peer-to-peer features (voice/video calls, WebRTC data channels) are completely unavailable.
- **Fetch / XMLHttpRequest** -- standard network requests appear unaffected. Relay WebSocket connections should work normally.
### Native Plugin Workarounds
Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView restrictions entirely:
| Blocked Web API | Capacitor Alternative |
|---|---|
| Web Share | `@capacitor/share` (already installed) |
| Notifications | `@capacitor/local-notifications` (already installed) |
| File downloads | `@capacitor/filesystem` + share (already implemented in `downloadFile.ts`) |
### Detection
The report used a scoring heuristic (8/12 key APIs blocked = 70%) to detect Lockdown Mode. There is no official API to query Lockdown Mode status. Detection relies on probing for the absence of multiple APIs that are specifically disabled by Lockdown Mode but normally present in Safari.
## Raw Diagnostic Report
For exact error messages, navigator properties, weight scores, and per-API diagnostic output, see [ios-report.txt](ios-report.txt).
## Guidance for Feature Decisions
When building new features, consider:
1. **Always provide pure-JS fallbacks** for any crypto or data-processing library that might ship a WASM build.
2. **Never depend on IndexedDB or OPFS** as the sole storage mechanism. Both are completely stripped. Always fall back to localStorage or in-memory stores.
3. **Avoid WebGL-dependent UI** for core functionality. Use it as a progressive enhancement with a CSS/Canvas 2D fallback.
4. **Use Capacitor plugins** for sharing, notifications, and file operations rather than web APIs -- they work on all native platforms regardless of Lockdown Mode.
5. **Test on a Lockdown Mode device** when shipping features that touch storage, crypto, or media APIs.
+229
View File
@@ -0,0 +1,229 @@
============================================================
LOCKDOWN MODE DETECTOR REPORT
2026-04-06T23:40:58.170Z
============================================================
VERDICT: Lockdown Mode Likely Active
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
Score: 70% (8/12 key APIs blocked)
============================================================
API TEST RESULTS (detailed)
============================================================
------------------------------------------------------------
[BLOCKED] IndexedDB (weight: 3)
Client-side structured storage
Result: Can't find variable: indexedDB
Diagnostics:
uncaught: Can't find variable: indexedDB
------------------------------------------------------------
[BLOCKED] WebAssembly (weight: 2)
Binary instruction execution
Result: WebAssembly is undefined
Diagnostics:
typeof WebAssembly: undefined
WebAssembly global does not exist
------------------------------------------------------------
[BLOCKED] Web Locks API (weight: 3)
Cross-tab resource coordination
Result: navigator.locks is undefined
Diagnostics:
typeof navigator.locks: undefined
'locks' in navigator: false
navigator.locks is falsy
------------------------------------------------------------
[BLOCKED] Speech Synthesis (weight: 3)
Web Speech API (text-to-speech)
Result: window.speechSynthesis is undefined
Diagnostics:
typeof window.speechSynthesis: undefined
'speechSynthesis' in window: false
typeof SpeechSynthesisUtterance: undefined
speechSynthesis is falsy
------------------------------------------------------------
[BLOCKED] FileReader API (weight: 2)
Local file reading interface
Result: FileReader is undefined
Diagnostics:
typeof FileReader: undefined
FileReader constructor does not exist
------------------------------------------------------------
[AVAILABLE] File Constructor (weight: 2)
File object creation
Result: File created: name=test.txt size=4
Diagnostics:
typeof File: function
calling new File(['test'], 'test.txt', {type:'text/plain'})...
succeeded
f.name: test.txt
f.size: 4
f.type: text/plain
f instanceof Blob: true
------------------------------------------------------------
[BLOCKED] WebGL (weight: 2)
GPU-accelerated graphics
Result: all WebGL contexts returned null
Diagnostics:
getContext('webgl2'): null
getContext('webgl'): null
getContext('experimental-webgl'): null
------------------------------------------------------------
[BLOCKED] WebGL2 (weight: 1)
Advanced GPU graphics context
Result: getContext('webgl2') returned null
Diagnostics:
getContext('webgl2'): null
------------------------------------------------------------
[BLOCKED] Service Worker (weight: 1)
Background script registration
Result: navigator.serviceWorker not present
Diagnostics:
'serviceWorker' in navigator: false
typeof navigator.serviceWorker: undefined
------------------------------------------------------------
[BLOCKED] Web Share API (weight: 0)
Native sharing interface
Result: navigator.share is undefined
Diagnostics:
typeof navigator.share: undefined
typeof navigator.canShare: undefined
------------------------------------------------------------
[BLOCKED] Gamepad API (weight: 1)
Game controller input
Result: navigator.getGamepads not present
Diagnostics:
'getGamepads' in navigator: false
------------------------------------------------------------
[BLOCKED] WebRTC (weight: 2)
Real-time peer communication
Result: RTCPeerConnection is undefined
Diagnostics:
typeof RTCPeerConnection: undefined
typeof webkitRTCPeerConnection: undefined
------------------------------------------------------------
[AVAILABLE] FontFace API (weight: 1)
Dynamic font loading
Result: status: loaded
Diagnostics:
typeof FontFace: function
new FontFace() succeeded
ff.status: unloaded
ff.family: test
ff.status after load: loaded
------------------------------------------------------------
[AVAILABLE] Remote Fonts (weight: 2)
Loading fonts from network via data URI
Result: API works, load rejected: A network error occurred.
Diagnostics:
FontFace created with data URI
ff.status before load: unloaded
caught: DOMException: A network error occurred.
------------------------------------------------------------
[AVAILABLE] JIT Compilation (weight: 2)
JavaScript JIT optimization heuristic
Result: 99.0ms for 1M iterations (JIT likely)
Diagnostics:
running 1,000,000 iterations of Math.sqrt*Math.sin...
elapsed: 99.00ms
sum (to prevent dead-code elimination): -681.7597
threshold: <150ms suggests JIT active
verdict: likely JIT
------------------------------------------------------------
[BLOCKED] Notifications API (weight: 1)
Push notification permission
Result: Notification not in window
Diagnostics:
'Notification' in window: false
typeof Notification: undefined
------------------------------------------------------------
[BLOCKED] WebCodecs (weight: 1)
Low-level VideoDecoder API
Result: VideoDecoder is undefined
Diagnostics:
typeof VideoDecoder: undefined
typeof VideoEncoder: undefined
typeof AudioDecoder: function
------------------------------------------------------------
[AVAILABLE] PDF Embed (weight: 2)
Inline PDF rendering via embed/pdfViewerEnabled
Result: pdfViewerEnabled is true
Diagnostics:
navigator.pdfViewerEnabled: true
typeof navigator.pdfViewerEnabled: boolean
created and appended <embed type=application/pdf>
navigator.mimeTypes['application/pdf']: [object MimeType]
------------------------------------------------------------
[BLOCKED] SharedArrayBuffer (weight: 1)
Shared memory between workers
Result: SharedArrayBuffer is undefined
Diagnostics:
typeof SharedArrayBuffer: undefined
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
------------------------------------------------------------
[BLOCKED] Cache API (weight: 1)
Programmatic HTTP cache (CacheStorage)
Result: caches not in window
Diagnostics:
'caches' in window: false
typeof caches: undefined
------------------------------------------------------------
[BLOCKED] OPFS (weight: 2)
Origin Private File System (navigator.storage.getDirectory)
Result: navigator.storage.getDirectory is not a function
Diagnostics:
typeof navigator.storage: object
typeof navigator.storage.getDirectory: undefined
getDirectory method does not exist
============================================================
NAVIGATOR INFO
============================================================
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
platform: iPhone
vendor: Apple Computer, Inc.
language: en-US
languages: en-US
cookieEnabled: true
doNotTrack: null
maxTouchPoints: 5
hardwareConcurrency: 4
deviceMemory: N/A
pdfViewerEnabled: true
webdriver: false
connection: unavailable
mediaDevices: unavailable
storage: available
serviceWorker: unavailable
credentials: available
bluetooth: unavailable
gpu (WebGPU): unavailable
screenWidth: 393
screenHeight: 852
devicePixelRatio: 3
colorDepth: 24
============================================================
END OF REPORT
============================================================
+1
View File
@@ -108,6 +108,7 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
- Focus on what the user sees/experiences, not internal implementation details
- Use the current date in YYYY-MM-DD format
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
+3 -1
View File
@@ -2,4 +2,6 @@ VITE_SENTRY_DSN="https://********************************@*****************.exam
VITE_PLAUSIBLE_DOMAIN="example.tld"
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
VITE_NOSTR_PUSH_PUBKEY=""
VITE_NOSTR_PUSH_PUBKEY=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
+75 -10
View File
@@ -26,19 +26,52 @@ 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
- 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"
--use-fallback-relays
--use-fallback-servers
build-web:
stage: build
timeout: 10 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
artifacts:
paths:
- public
only:
variables:
- $CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
- dist/
build-apk:
stage: build
@@ -112,8 +145,9 @@ build-apk:
- npx vite build -l error
- cp dist/index.html dist/404.html
# Sync web assets to Capacitor Android project
# Sync web assets to Capacitor Android project and register local plugins
- npx cap sync android
- node scripts/patch-cap-config.mjs
# Build signed release APK
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
@@ -185,6 +219,8 @@ publish-zapstore:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
variables:
SIGN_WITH: $ZAPSTORE_BUNKER_URL
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
BLOSSOM_URL: "https://blossom.ditto.pub"
script:
- go install github.com/zapstore/zsp@latest
@@ -198,4 +234,33 @@ 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
publish-google-play:
stage: publish
image: ruby:3.3
needs:
- build-apk
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- gem install fastlane --no-document
# Decode base64-encoded service account JSON to a temp file
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
# Upload the AAB to Google Play production track
- >-
fastlane supply
--aab artifacts/Ditto.aab
--package_name pub.ditto.app
--track production
--json_key /tmp/play-service-account.json
--skip_upload_metadata
--skip_upload_changelogs
--skip_upload_images
--skip_upload_screenshots
--skip_upload_apk
# Clean up
- rm -f /tmp/play-service-account.json
@@ -0,0 +1,68 @@
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
## Related Issue
<!-- Link the GitLab issue. MRs without a linked issue will not be reviewed. -->
Closes #
## What Changed
<!-- 1-3 sentences: what you changed and why. -->
## Live Preview
<!-- REQUIRED for UI changes. Deploy your branch and paste the URL. -->
<!-- Example: npx surge dist your-branch.surge.sh -->
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
## Screenshots
<!-- REQUIRED for UI changes. Show before and after. -->
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
**Before:**
**After:**
## Philosophy Alignment
<!-- Answer this question for your change: -->
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
<!-- and more peaceful to inhabit?" -->
<!-- See: https://about.ditto.pub/philosophy -->
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
## How to Test
<!-- Steps a reviewer can follow to verify this works. -->
1.
2.
3.
## Self-Review Checklist
<!-- Complete ALL items. MRs with unchecked boxes will not be reviewed. -->
<!-- Check a box: replace [ ] with [x] -->
### Process
- [ ] I read `AGENTS.md` before starting
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- [ ] I used plan/research mode before writing code
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
### Self-review
Copy-paste this into your AI tool and fix any findings before submitting:
> Review this diff against the self-review checklist in CONTRIBUTING.md step 8. Read that file first, then check every item. For each finding, state the file, line, and issue.
- [ ] I ran the self-review prompt above and addressed all findings
### Testing
- [ ] I ran `npm run test` locally and it passes
- [ ] I tested the change manually in the browser
+259 -18
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
@@ -407,6 +409,74 @@ Without filtering approvals by the moderator list, anyone could publish kind 455
Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.).
#### Sanitizing URLs from Event Data
**CRITICAL**: Any URL extracted from Nostr event tags, content, or metadata fields is **untrusted user input**. Malicious URLs can cause harm in many ways beyond `javascript:` XSS — `data:` URIs for resource exhaustion, `http://` URLs leaking user IPs without TLS, relative paths triggering unintended requests to the app's own origin, and more. Reasoning about which rendering context is "safe enough" to skip sanitization is fragile and error-prone.
**Rule: sanitize every event-sourced URL unconditionally**, regardless of where it will be used (`href`, `img src`, `style`, etc.). Use `sanitizeUrl()` from `@/lib/sanitizeUrl`:
```typescript
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// Single URL — returns the normalised href, or undefined if not valid https
const url = sanitizeUrl(getTag(event.tags, 'url'));
if (url) {
// safe to use in any context
}
// Array of URLs — filter out invalid entries
const links = getAllTags(event.tags, 'r')
.map(([, v]) => sanitizeUrl(v))
.filter((v): v is string => !!v);
```
`sanitizeUrl` accepts `string | undefined | null` and returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
**Best practice — sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
**When sanitization is NOT required:**
- URLs extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`)
- Hardcoded or application-generated URLs (relay configs, internal routes, etc.)
- URLs displayed as plain text without being placed into any HTML attribute or CSS value
#### Preventing CSS Injection from Event Data
**CRITICAL**: Any value from a Nostr event that is interpolated into a CSS string (inside a `<style>` element or inline `style` attribute) is a CSS injection vector. A malicious value containing `"`, `)`, `}`, or `;` can break out of the CSS context and inject arbitrary rules — for example, overlaying phishing content or hiding UI elements.
**Common CSS injection surfaces:**
- `background-image: url("${url}")` — a URL with `"); body { display:none }` breaks out
- `font-family: "${family}"` — a family name with `"; } body { visibility:hidden } .x {` breaks out
- `@font-face { src: url("${url}") }` — same risk as background URLs
**Mitigation strategy — sanitize at the parse layer:**
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
```typescript
// ❌ UNSAFE — raw event data interpolated into CSS
const bgUrl = getTagValue(event.tags, 'bg');
style.textContent = `body { background-image: url("${bgUrl}"); }`;
const family = getTagValue(event.tags, 'f');
style.textContent = `html { font-family: "${family}"; }`;
// ✅ SAFE — URLs validated, strings sanitised
import { sanitizeUrl } from '@/lib/sanitizeUrl';
const bgUrl = sanitizeUrl(getTagValue(event.tags, 'bg'));
if (bgUrl) {
style.textContent = `body { background-image: url("${bgUrl}"); }`;
}
// For non-URL strings, allowlist safe characters only
const safeFamily = family.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
style.textContent = `html { font-family: "${safeFamily}"; }`;
```
**Rule of thumb**: Never interpolate untrusted strings into CSS without sanitisation. If it's a URL, use `sanitizeUrl()`. If it's any other string, strip characters that can break out of the CSS string context.
### The `useNostr` Hook
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
@@ -693,6 +763,88 @@ 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, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
#### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
- **Update (`prev` provided)**: `published_at` is preserved from the old event
- **Old event lacks `published_at`**: nothing is fabricated
- **Caller already set `published_at` in tags**: left alone
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
#### When to Check for Collisions
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
#### Implementation Pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
@@ -954,6 +1106,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:
@@ -1241,6 +1403,10 @@ Run available tools in this priority order:
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
### Contributing Guide
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
### Using Git
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
@@ -1315,10 +1481,10 @@ 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)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only)
### Creating a Release
@@ -1328,7 +1494,7 @@ Releases are triggered by pushing a version tag. Use the npm script:
npm run release
```
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, and `publish-zapstore` stages.
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` stages.
### Zapstore Publishing
@@ -1355,19 +1521,94 @@ 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
### Google Play Publishing
The project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track.
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
#### Initial Setup (one-time)
1. Create or reuse a project in the [Google Cloud Console](https://console.cloud.google.com/projectcreate)
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`
5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
#### Key Points
- The job uploads the signed AAB (not APK) since Google Play requires App Bundles
- Uploads go directly to the **production** track -- Google's review process still applies before the update reaches users
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.)
- The same signing keystore used for Zapstore is used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`)
+362
View File
@@ -1,5 +1,367 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [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 now display in feeds and detail pages with hero images, icons, 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 login flows with Amber, Primal, and other signing apps
### 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
- 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 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
- Event links now route correctly for all event types
- 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
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- 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 divines experience on both mobile and desktop with floating controls
- 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
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
+184
View File
@@ -0,0 +1,184 @@
# Contributing to Ditto
We welcome contributions, but we have high standards. Ditto is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
**Required reading before you start:**
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
- [Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
## Understanding Ditto
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
### The product decision filter
Every change to Ditto should pass this test:
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
- **Magnetic** -- Ditto attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
- **Threatening to the status quo** -- Ditto threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
- **Peaceful to inhabit** -- Ditto displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
### What Ditto is NOT
- A Twitter/X clone with decentralization bolted on
- A place to replicate features that mainstream platforms already do well
- A showcase for generic UI components or boilerplate social features
### What Ditto IS
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
- A place where profiles feel like worlds, not business cards
- The most fun you've had on the internet in years
Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete vision.
## What we accept
### Bug fixes
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
### New features and significant changes
Every feature MR must link to an existing open issue and clearly align with the [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong.
If you have an idea for a feature that doesn't have an issue yet:
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://about.ditto.pub/contributing)).
2. Prove it works and get user feedback.
3. Open an issue to discuss integration.
**Feature MRs that don't link to an issue or don't align with the Ditto Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
## Required tools
- **Claude Opus 4.6** (or the latest frontier model) -- not Sonnet, not GPT-4o, not local models. Quality depends on model quality.
- **An AI coding agent with plan/research mode** -- [OpenCode](https://opencode.ai), [Shakespeare](https://shakespeare.diy), Cursor, or similar.
- **Node.js 22+** and npm 10.9.4+.
## The contribution workflow
Follow these steps in order. Skipping steps is the most common reason MRs are rejected.
### 1. Ask: does anyone need this?
Before writing a single line of code, answer this honestly. For bug fixes this is straightforward -- someone hit the bug. For features, it requires more thought. Is there evidence of real user demand? Is the underlying technology mature enough? A beautifully written feature for a nonexistent user base is the wrong thing to build. If you can't point to a concrete user need, reconsider.
### 2. Understand the issue
Read the issue thoroughly. If anything is unclear, ask in the issue comments before writing code. Understand not just *what* to change, but *why* -- what problem does this solve for users?
### 3. Read the codebase conventions
Read `AGENTS.md` in the repo root. This is the single source of truth for how code should be written in this project. Your AI tool should load this file automatically. If it doesn't, paste it in or configure your tool to read it.
### 4. Read the philosophy
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
### 5. Plan before you code
Start your AI tool in **plan mode** (or research/think mode). Spend the first few prompts:
- Exploring the existing codebase to understand how similar features are implemented
- Reading the files you'll need to modify
- Proposing an approach
Do not write code until you have a plan. The most expensive mistake is implementing the wrong approach.
### 6. Implement
Switch to code mode and implement your plan. Use Opus 4.6 or equivalent.
### 7. Run the test suite
```sh
npm run test
```
This runs type-checking, linting, unit tests, and a production build. All must pass. Do not submit an MR with a failing test suite.
### 8. Self-review
Run this prompt against your diff (copy the full `git diff` output and paste it to your AI tool along with this prompt):
```
Review this diff as if you are a senior maintainer of this codebase who has to
maintain it long-term. For each finding, state the file, line, and issue.
- [ ] Does the diff contain changes that weren't requested? Flag anything out of scope.
- [ ] Is there dead code, commented-out blocks, or debug artifacts left in?
- [ ] Are there placeholder comments like "// In a real app..." or "// TODO: implement"?
- [ ] For every value displayed to a user, can you trace it from source to render without a gap?
- [ ] Are error, loading, and empty states all handled -- and in the right order?
- [ ] Does a mutation reflect in the UI without requiring a manual refresh?
- [ ] Is there a new read/write path that assumes fresh data but could get a stale cache?
- [ ] For replaceable/addressable Nostr events: is fetchFreshEvent used before mutation?
- [ ] Does anything new block the critical render path or fire N+1 network requests?
- [ ] Are Nostr queries efficient (combined kinds, relay-level filtering vs client-side)?
- [ ] Are user inputs used in queries or rendered as content without sanitization?
- [ ] Were existing patterns/conventions in AGENTS.md ignored in favor of something novel?
- [ ] Are secrets, keys, or env-specific values hardcoded?
- [ ] Does the code use the `any` type anywhere?
- [ ] Is the code Capacitor-compatible (no `<a download>`, no `window.open()`)?
- [ ] Are new Nostr event kinds documented in NIP.md with links to relevant specs?
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
Then answer: "If you were the people who have to maintain this codebase and deal
with all long-term issues, what would be your biggest concerns about this
implementation?"
```
Address every finding before submitting.
### 9. Deploy a live preview
Deploy your branch so reviewers can test it without pulling your code:
```sh
npm run build
npx surge dist your-branch-name.surge.sh
```
Or use Netlify, Vercel, or any static hosting. Include the live preview URL in your MR description.
### 10. Take screenshots
Capture before and after screenshots of any UI changes. Include them directly in the MR description. If your change has no visual component, state that explicitly.
### 11. Submit
Fill out every field in the MR template. Incomplete MRs will not be reviewed.
## What gets your MR closed without review
- No linked issue
- Feature MRs with no clear alignment with the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
- Incomplete MR template (missing checklist, screenshots, or preview URL)
- Changes that go beyond what was asked for (scope creep)
- Placeholder code, dead code, or debug artifacts
- Evidence of low-quality AI generation ("In a real application..." comments, hallucinated APIs, generic template code)
- Failing test suite
- No evidence of planning (code-first, think-later approach produces recognizable patterns)
- Undocumented Nostr event kinds (new kinds must be in NIP.md)
- Large binary assets committed to git (images >100KB, fonts, videos)
- Security issues (dangerouslySetInnerHTML, eval, innerHTML, unsanitized user input)
## MR review process
1. The CI pipeline validates your MR description automatically. If it fails, read the error message and fix your MR description.
2. Maintainers will review your MR when all CI checks pass and the template is complete.
3. If changes are requested, address them promptly. Stale MRs will be closed.
We appreciate your interest in contributing. These standards exist because reviewing a low-quality MR takes 3x longer than doing the work ourselves. Help us help you by following the process.
+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.
+12 -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)
@@ -138,6 +138,17 @@ src/
public/ Static assets, icons, manifest
```
## Contributing
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
- **Bug fixes**: One bug, one MR. Keep it small and focused.
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
## License
[AGPL-3.0](LICENSE)
+4 -3
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.1.1"
versionName "2.6.6"
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'
}
}
}
+4 -1
View File
@@ -11,9 +11,12 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capgo-capacitor-autofill-save-password')
implementation project(':capacitor-secure-storage-plugin')
}
+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,7 +1,10 @@
package pub.ditto.app;
import android.content.SharedPreferences;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log;
import com.getcapacitor.Plugin;
@@ -14,6 +17,10 @@ import org.json.JSONArray;
/**
* Capacitor plugin that allows the JS layer to configure the native
* notification polling service with the user's pubkey and relay URLs.
*
* Supports two notification styles:
* - "push" (default): no foreground service, relies on push notifications
* - "persistent": starts NotificationRelayService as a foreground service
*/
@CapacitorPlugin(name = "DittoNotification")
public class DittoNotificationPlugin extends Plugin {
@@ -24,7 +31,10 @@ public class DittoNotificationPlugin extends Plugin {
@PluginMethod
public void configure(PluginCall call) {
String userPubkey = call.getString("userPubkey");
String notificationStyle = call.getString("notificationStyle", "push");
String relayUrlsRaw = null;
String enabledKindsRaw = null;
String authorsRaw = null;
try {
JSONArray relayUrls = call.getArray("relayUrls");
@@ -35,20 +45,80 @@ public class DittoNotificationPlugin extends Plugin {
Log.w(TAG, "Failed to read relayUrls", e);
}
try {
JSONArray enabledKinds = call.getArray("enabledKinds");
if (enabledKinds != null) {
enabledKindsRaw = enabledKinds.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read enabledKinds", e);
}
try {
JSONArray authors = call.getArray("authors");
if (authors != null) {
authorsRaw = authors.toString();
}
} catch (Exception e) {
Log.w(TAG, "Failed to read authors", e);
}
SharedPreferences prefs = getContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
if (userPubkey != null && relayUrlsRaw != null) {
prefs.edit()
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw)
.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw);
.putString("notificationStyle", notificationStyle);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
if (authorsRaw != null) {
editor.putString("authors", authorsRaw);
} else {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
} else {
// Clear config (user logged out)
prefs.edit().clear().apply();
Log.d(TAG, "Config cleared (user logged out)");
}
// Start or stop the foreground service based on style
manageService(notificationStyle, userPubkey != null && relayUrlsRaw != null);
call.resolve();
}
/**
* Start the foreground service when style is "persistent" and config is valid.
* Stop it otherwise.
*/
private void manageService(String style, boolean hasConfig) {
Context ctx = getContext();
Intent serviceIntent = new Intent(ctx, NotificationRelayService.class);
if ("persistent".equals(style) && hasConfig) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(serviceIntent);
} else {
ctx.startService(serviceIntent);
}
Log.d(TAG, "Started NotificationRelayService (persistent mode)");
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w(TAG, "Could not start foreground service: " + e.getMessage());
} else {
Log.w(TAG, "Failed to start service", e);
}
}
} else {
ctx.stopService(serviceIntent);
Log.d(TAG, "Stopped NotificationRelayService (push mode or no config)");
}
}
}
@@ -1,7 +1,9 @@
package pub.ditto.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -11,32 +13,36 @@ import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final String PREFS_NAME = "ditto_notification_config";
@Override
protected void onCreate(Bundle savedInstanceState) {
// Register the native notification config plugin before super.onCreate
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(SandboxPlugin.class);
super.onCreate(savedInstanceState);
// Start the persistent relay connection service.
// On Android 12+ (API 31+) the system may throw
// ForegroundServiceStartNotAllowedException if the foreground service
// time limit for this type has already been exhausted. We catch it so
// the app continues to run normally; the alarm inside the service will
// retry at the next scheduled interval.
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
// Only start the foreground service if the user has opted into
// "persistent" notification style. Default is "push" (no service).
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String style = prefs.getString("notificationStyle", "push");
if ("persistent".equals(style)) {
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
}
}
}
@@ -243,6 +243,8 @@ public class NotificationRelayService extends Service {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String userPubkey = prefs.getString("userPubkey", null);
String relayUrlsJson = prefs.getString("relayUrls", null);
String enabledKindsJson = prefs.getString("enabledKinds", null);
String authorsJson = prefs.getString("authors", null);
if (userPubkey == null || relayUrlsJson == null) {
Log.d(TAG, "No config, skipping fetch");
@@ -268,10 +270,17 @@ public class NotificationRelayService extends Service {
return;
}
fetch(relayUrls.get(relayIndex), userPubkey);
List<Integer> enabledKinds = parseEnabledKinds(enabledKindsJson);
if (enabledKinds.isEmpty()) {
Log.d(TAG, "No enabled kinds, skipping fetch");
releaseFetchWakeLock();
return;
}
List<String> authors = parseAuthors(authorsJson);
fetch(relayUrls.get(relayIndex), userPubkey, enabledKinds, authors);
}
private void fetch(String relayUrl, String userPubkey) {
private void fetch(String relayUrl, String userPubkey, List<Integer> enabledKinds, List<String> authors) {
long since = poller.getLastSeenTimestamp();
if (since == 0) {
since = (System.currentTimeMillis() / 1000) - 300; // 5 min ago on first run
@@ -284,7 +293,9 @@ public class NotificationRelayService extends Service {
try {
JSONObject filter = new JSONObject();
JSONArray kinds = new JSONArray();
kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111); kinds.put(8211);
for (int kind : enabledKinds) {
kinds.put(kind);
}
filter.put("kinds", kinds);
JSONArray pTags = new JSONArray();
pTags.put(userPubkey);
@@ -292,6 +303,15 @@ public class NotificationRelayService extends Service {
filter.put("since", since + 1);
filter.put("limit", FETCH_LIMIT);
// When "only from people I follow" is enabled, restrict to those authors
if (!authors.isEmpty()) {
JSONArray authorsArr = new JSONArray();
for (String author : authors) {
authorsArr.put(author);
}
filter.put("authors", authorsArr);
}
JSONArray req = new JSONArray();
req.put("REQ");
req.put(currentSubId);
@@ -397,7 +417,8 @@ public class NotificationRelayService extends Service {
}
Log.d(TAG, "Retrying in " + backoffMs + "ms on relay " + relayIndex);
Runnable retry = () -> fetch(relayUrls.get(relayIndex), userPubkey);
// Re-read config from prefs on retry so enabled kinds stay current.
Runnable retry = this::runFetchCycle;
handler.postDelayed(retry, backoffMs);
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
@@ -515,6 +536,46 @@ public class NotificationRelayService extends Service {
return urls;
}
/**
* Parse the authors filter from JSON. Returns an empty list when the value
* is null or invalid (meaning no author restriction).
*/
private List<String> parseAuthors(String json) {
List<String> authors = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
authors.add(arr.getString(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse authors", e);
}
}
return authors;
}
/**
* Parse the enabled notification kinds from JSON. Returns an empty list
* when the value is null or invalid — the caller should skip polling
* when the list is empty (the JS layer always provides kinds via
* DittoNotification.configure in the same write as pubkey/relays).
*/
private List<Integer> parseEnabledKinds(String json) {
List<Integer> kinds = new ArrayList<>();
if (json != null) {
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
kinds.add(arr.getInt(i));
}
} catch (JSONException e) {
Log.w(TAG, "Failed to parse enabled kinds", e);
}
}
return kinds;
}
private Notification buildForegroundNotification() {
Intent notificationIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(
@@ -0,0 +1,552 @@
package pub.ditto.app;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
*
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
* The React code can serve files identically regardless of platform.
*/
@CapacitorPlugin(name = "SandboxPlugin")
public class SandboxPlugin extends Plugin {
private static final String TAG = "SandboxPlugin";
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@PluginMethod
public void create(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
if (sandboxes.containsKey(sandboxId)) {
call.reject("Sandbox already exists: " + sandboxId);
return;
}
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
sandboxes.put(sandboxId, sandbox);
// Add the container (WebView + spinner overlay) on top of the
// Capacitor WebView. The parent is a CoordinatorLayout — using
// the wrong LayoutParams type causes a ClassCastException when
// it intercepts touch events.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
parent.addView(sandbox.container, params);
// The spinner is now visible. Navigation is deferred until the
// JS layer calls navigate() — this allows the caller to
// pre-fetch blobs while the spinner animates.
call.resolve();
});
}
@PluginMethod
public void navigate(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
call.resolve();
});
}
@PluginMethod
public void updateFrame(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.container.setLayoutParams(params);
call.resolve();
});
}
@PluginMethod
public void respondToFetch(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
String requestId = call.getString("requestId");
if (requestId == null) {
call.reject("Missing required parameter: requestId");
return;
}
JSObject response = call.getObject("response");
if (response == null) {
call.reject("Missing required parameter: response");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
int status = response.optInt("status", 200);
String statusText = response.optString("statusText", "OK");
String bodyBase64 = response.optString("body", null);
Map<String, String> headers = new HashMap<>();
JSONObject headersObj = response.optJSONObject("headers");
if (headersObj != null) {
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
String key = it.next();
headers.put(key, headersObj.optString(key));
}
}
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
call.resolve();
}
@PluginMethod
public void postMessage(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject message = call.getObject("message");
if (message == null) {
call.reject("Missing required parameter: message");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
call.resolve();
}
@PluginMethod
public void destroy(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.remove(sandboxId);
if (sandbox != null) {
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
if (parent != null) {
parent.removeView(sandbox.container);
}
sandbox.webView.destroy();
}
call.resolve();
});
}
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("requestId", requestId);
data.put("request", request);
notifyListeners("fetch", data);
}
void emitScriptMessage(String sandboxId, JSObject message) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("message", message);
notifyListeners("scriptMessage", data);
}
/**
* A single sandboxed WebView instance.
*/
private static class SandboxInstance {
final String id;
/** Wrapper layout that holds the WebView and the loading overlay. */
final FrameLayout container;
final WebView webView;
final SandboxPlugin plugin;
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
/** Native spinner overlay, shown while the sandbox content loads. */
private ProgressBar spinner;
SandboxInstance(String id, SandboxPlugin plugin) {
this.id = id;
this.plugin = plugin;
this.container = new FrameLayout(plugin.getActivity());
this.webView = new WebView(plugin.getActivity());
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setDatabaseEnabled(true);
webView.setBackgroundColor(Color.parseColor("#14161f"));
// Add JavaScript interface for script->native communication.
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
// Inject the bridge script and intercept requests.
webView.setWebViewClient(new SandboxWebViewClient(this));
// Build the container: WebView fills it, spinner overlays on top.
container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// Native spinner overlay — uses the Android indeterminate
// ProgressBar which animates on the render thread, so it keeps
// spinning even when the main/IO threads are busy.
spinner = new ProgressBar(plugin.getActivity());
spinner.setIndeterminate(true);
spinner.getIndeterminateDrawable().setColorFilter(
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
container.addView(spinner, spinnerParams);
// Dark background behind the spinner.
View overlay = new View(plugin.getActivity());
overlay.setBackgroundColor(Color.parseColor("#14161f"));
// Insert the overlay between the WebView (index 0) and spinner (index 1)
// so it covers the WebView but sits behind the spinner.
container.addView(overlay, 1, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
}
/** Remove the native loading overlay. Safe to call multiple times. */
void hideSpinner() {
if (spinner != null) {
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
spinner = null;
}
}
private static int dpToPx(SandboxPlugin plugin, int dp) {
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
void postMessageToWebView(String jsonString) {
String js = "(function() { " +
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
"} " +
"})();";
webView.evaluateJavascript(js, null);
}
void resolveRequest(String requestId, int status, String statusText,
Map<String, String> headers, String bodyBase64) {
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) return;
byte[] bodyBytes = null;
if (bodyBase64 != null && !bodyBase64.equals("null")) {
try {
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
} catch (Exception e) {
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
}
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse response = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(response);
}
}
/**
* WebViewClient that intercepts all requests and forwards them to JS.
*/
private static class SandboxWebViewClient extends WebViewClient {
private final SandboxInstance sandbox;
private boolean bridgeInjected = false;
SandboxWebViewClient(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
// Only intercept requests to the sandbox domain.
if (!url.contains(".sandbox.native")) {
return null;
}
String requestId = UUID.randomUUID().toString();
// Create a pending request with a blocking latch.
PendingRequest pending = new PendingRequest();
sandbox.pendingRequests.put(requestId, pending);
// Rewrite URL to include the sandbox ID for the JS handler.
String path = request.getUrl().getPath();
if (path == null || path.isEmpty()) path = "/";
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
// Serialise the request.
JSObject serialisedRequest = new JSObject();
serialisedRequest.put("url", rewrittenURL);
serialisedRequest.put("method", request.getMethod());
JSObject headers = new JSObject();
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
headers.put(entry.getKey(), entry.getValue());
}
serialisedRequest.put("headers", headers);
serialisedRequest.put("body", JSONObject.NULL);
// Emit to JS.
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
// Block until JS responds. Each asset is fetched from a Blossom
// server over the network, so we need a generous timeout. The
// WebView IO thread pool has ~6 threads; if all are blocked,
// subsequent requests queue until a thread frees up.
WebResourceResponse response = pending.awaitResponse(60000);
if (response != null) {
return response;
}
// Timeout — return error response.
sandbox.pendingRequests.remove(requestId);
return new WebResourceResponse(
"text/plain", "UTF-8", 504,
"Gateway Timeout", new HashMap<>(),
new ByteArrayInputStream("Request timed out".getBytes())
);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (!bridgeInjected) {
bridgeInjected = true;
view.evaluateJavascript(getBridgeScript(), null);
}
// Remove the native spinner once the first page has finished
// loading (all initial resources resolved). This runs on the
// main thread, so the removal is safe.
sandbox.hideSpinner();
}
private String getBridgeScript() {
return "(function() {" +
"'use strict';" +
"var messageListeners = [];" +
"window.__sandboxBridge = {" +
" onMessage: function(data) {" +
" var event = {" +
" data: data," +
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
" source: window.parent," +
" type: 'message'" +
" };" +
" for (var i = 0; i < messageListeners.length; i++) {" +
" try { messageListeners[i](event); } catch(e) {}" +
" }" +
" }" +
"};" +
"var origAdd = window.addEventListener;" +
"window.addEventListener = function(type, fn, opts) {" +
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
" return origAdd.call(window, type, fn, opts);" +
"};" +
"var origRemove = window.removeEventListener;" +
"window.removeEventListener = function(type, fn, opts) {" +
" if (type === 'message') {" +
" var idx = messageListeners.indexOf(fn);" +
" if (idx !== -1) messageListeners.splice(idx, 1);" +
" }" +
" return origRemove.call(window, type, fn, opts);" +
"};" +
"if (!window.parent || window.parent === window) window.parent = {};" +
"window.parent.postMessage = function(data) {" +
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
" }" +
"};" +
"})();";
}
}
/**
* JavaScript interface exposed to the sandbox WebView.
*/
private static class SandboxBridge {
private final SandboxInstance sandbox;
SandboxBridge(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@JavascriptInterface
public void postMessage(String json) {
try {
JSONObject obj = new JSONObject(json);
JSObject jsObj = new JSObject();
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
String key = it.next();
jsObj.put(key, obj.get(key));
}
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
} catch (JSONException e) {
Log.w(TAG, "Failed to parse script message", e);
}
}
}
/**
* A pending request that blocks the WebViewClient IO thread until JS
* responds with the complete resource.
*/
private static class PendingRequest {
private volatile WebResourceResponse response;
private final CountDownLatch latch = new CountDownLatch(1);
void resolve(WebResourceResponse response) {
this.response = response;
latch.countDown();
}
WebResourceResponse awaitResponse(long timeoutMs) {
try {
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return response;
}
}
}
+11 -2
View File
@@ -8,11 +8,20 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capgo-capacitor-autofill-save-password'
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
include ':capacitor-secure-storage-plugin'
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
+9 -4
View File
@@ -5,8 +5,6 @@ const config: CapacitorConfig = {
appName: 'Ditto',
webDir: 'dist',
server: {
// Handle deep links from your domain
hostname: 'ditto.pub',
androidScheme: 'https',
iosScheme: 'https'
},
@@ -17,9 +15,16 @@ const config: CapacitorConfig = {
},
ios: {
backgroundColor: '#14161f',
contentInset: 'automatic',
contentInset: 'never',
scheme: 'Ditto'
}
},
plugins: {
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
insetsHandling: 'css',
},
},
};
export default config;
+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
+254
View File
@@ -0,0 +1,254 @@
# Blobbi Tag Schema
> **Product Specification** - This document is the canonical source of truth for Blobbi tag definitions.
> The runtime schema at `src/lib/blobbi-tag-schema.ts` MUST align with this spec.
## Overview
Blobbi events (Kind 31124) use tags to store all state data. This document defines:
- All valid tags and their purposes
- Which tags are required vs optional
- Which tags persist across stage transitions
- Which tags should be removed during transitions
- Deprecated tags that should be filtered out
---
## Tag Categories
### 1. System / Metadata Tags
Core protocol-level tags required for event identification and ecosystem membership.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `d` | **Yes** | egg, baby, adult | Yes | system | `blobbi-{pubkeyPrefix12}-{petId10}` | Unique identifier (addressable event d-tag) |
| `b` | **Yes** | egg, baby, adult | Yes | system | `blobbi:ecosystem:v1` | Ecosystem namespace identifier |
| `t` | **Yes** | egg, baby, adult | Yes | system | `blobbi` | Topic tag for discoverability |
| `client` | No | egg, baby, adult | Yes | system | `blobbi` | Client identifier |
### 2. Core Identity Tags
Tags that define the Blobbi's unique identity. These MUST be preserved across all transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `name` | **Yes** | egg, baby, adult | Yes | user | string | Display name (set during adoption) |
| `seed` | **Yes** | egg, baby, adult | Yes | system | 64 hex chars | Deterministic seed for visual traits |
| `generation` | No | egg, baby, adult | Yes | system | positive integer | Lineage generation (default: 1) |
**Important**: The `seed` is derived once at creation using `sha256("blobbi:v1|{pubkey}:{d}:{createdAt}")` and MUST NEVER be recomputed.
### 3. Visual Trait Tags
Tags derived deterministically from the seed. These are stored explicitly for fast rendering and compatibility.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `base_color` | No | egg, baby, adult | Yes | generated | CSS hex (e.g., `#F59E0B`) | Primary color |
| `secondary_color` | No | egg, baby, adult | Yes | generated | CSS hex | Secondary/accent color |
| `eye_color` | No | egg, baby, adult | Yes | generated | CSS hex | Eye color |
| `pattern` | No | egg, baby, adult | Yes | generated | `solid\|spotted\|striped\|gradient` | Visual pattern type |
| `special_mark` | No | egg, baby, adult | Yes | generated | `none\|star\|heart\|sparkle\|blush` | Special decoration |
| `size` | No | egg, baby, adult | Yes | generated | `small\|medium\|large` | Size category |
**Regenerable**: These tags CAN be regenerated from the seed if missing. However, they should be preserved when present.
### 4. Personality / Trait Tags
Character traits that define the Blobbi's personality. These are generated at creation and MUST persist.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `personality` | No | egg, baby, adult | Yes | generated | string | Core personality type |
| `trait` | No | egg, baby, adult | Yes | generated | string | Character trait modifier |
| `favorite_food` | No | egg, baby, adult | Yes | generated | string | Preferred food type |
| `voice_type` | No | egg, baby, adult | Yes | generated | string | Voice characteristic |
| `mood` | No | egg, baby, adult | Yes | computed | string | Current emotional state |
**Not Regenerable**: These tags are generated once and MUST be preserved. Do NOT invent values for existing Blobbis that lack these tags.
### 5. Stat Tags
Numeric values representing the Blobbi's current condition. These are actively computed and change frequently.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `hunger` | No | egg, baby, adult | No | computed | 1-100 | 100 | Fullness level |
| `happiness` | No | egg, baby, adult | No | computed | 1-100 | 100 | Happiness level |
| `health` | No | egg, baby, adult | No | computed | 1-100 | 100 | Health level |
| `hygiene` | No | egg, baby, adult | No | computed | 1-100 | 100 | Cleanliness level |
| `energy` | No | egg, baby, adult | No | computed | 1-100 | 100 | Energy level |
**Stage Transition Behavior**:
- **Hatch (egg → baby)**: `health` inherited from egg, others reset to 100
- **Evolve (baby → adult)**: All stats inherited from baby (after decay)
### 6. State / Lifecycle Tags
Tags that track the Blobbi's current lifecycle state.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `stage` | **Yes** | egg, baby, adult | No | system | `egg\|baby\|adult` | Current lifecycle stage |
| `state` | **Yes** | egg, baby, adult | No | system | `active\|sleeping\|hibernating\|incubating\|evolving` | Activity state |
| `last_interaction` | **Yes** | egg, baby, adult | No | system | Unix timestamp | Last user action |
| `last_decay_at` | No | egg, baby, adult | No | system | Unix timestamp | Decay checkpoint |
**State Constraints**:
- `incubating` is only valid for `stage: egg`
- `evolving` is only valid for `stage: baby`
- After hatch/evolve completes, `state` MUST be set to `active`
### 7. Task System Tags
Temporary tags used during incubation and evolution processes. These are REMOVED after stage transitions.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `state_started_at` | No | egg, baby | No | system | Unix timestamp | When incubating/evolving started |
| `task` | No | egg, baby | No | computed | `["task", "name:value"]` | Task progress (multiple allowed) |
| `task_completed` | No | egg, baby | No | computed | `["task_completed", "name"]` | Completed tasks (multiple allowed) |
**Transition Behavior**: ALL task system tags MUST be removed when hatch or evolve completes.
### 8. Progression Tags
Long-term progress tracking that persists across all stages.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `experience` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Total XP |
| `care_streak` | No | egg, baby, adult | Yes | computed | non-negative int | 0 | Consecutive care days |
### 9. Social / Flag Tags
User preferences and computed flags.
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|-----|----------|--------|------------|--------|--------|---------|-------------|
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
### 10. Evolution Tags
Tags specific to adult Blobbis.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `adult_type` | No | adult | Yes | computed | string | Evolution form type |
### 11. Extension Tags
Optional tags for themes and crossover features.
| Tag | Required | Stages | Persistent | Source | Format | Description |
|-----|----------|--------|------------|--------|--------|-------------|
| `theme` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Theme variant |
| `crossover_app` | No | egg, baby, adult | Yes | system | string (e.g., `divine`) | Crossover app identifier |
---
## Deprecated Tags
These tags are from legacy versions and MUST be removed when republishing events.
| Tag | Reason | Replaced By |
|-----|--------|-------------|
| `shell_integrity` | Eggs use standard `health` stat | `health` |
| `egg_temperature` | Warmth handled via UI props | N/A |
| `incubation_progress` | Replaced by task system | `task`, `task_completed` |
| `egg_status` | Replaced by standard state | `state` |
| `fees` | Removed | N/A |
| `incubation_time` | Uses state_started_at | `state_started_at` |
| `start_incubation` | Uses state_started_at | `state_started_at` |
| `interact_6_progress` | Legacy interaction tracking | `["task", "interactions:N"]` |
---
## Stage Transition Rules
### Hatch (egg → baby)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``baby`
- `state``active`
- `hunger``100`
- `happiness``100`
- `hygiene``100`
- `energy``100`
- `health` → (inherited from egg after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- All system tags (`d`, `b`, `t`, `client`)
- All identity tags (`name`, `seed`, `generation`)
- All visual tags (colors, pattern, size)
- All personality tags (if present)
- All progression tags (`experience`, `care_streak`)
- All social tags (`breeding_ready`)
- All extension tags (`theme`, `crossover_app`)
### Evolve (baby → adult)
**Tags to REMOVE**:
- `task`
- `task_completed`
- `state_started_at`
**Tags to UPDATE**:
- `stage``adult`
- `state``active`
- All stats → (inherited from baby after decay)
- `last_interaction` → current timestamp
- `last_decay_at` → current timestamp
**Tags to PRESERVE (all persistent tags)**:
- Same as hatch, plus all stats are inherited (not reset)
**Tags to ADD (optional)**:
- `adult_type` → computed based on care history
---
## Migration Rules
When migrating legacy Blobbis to canonical format:
1. **Always preserve existing values** - Do not regenerate tags that already exist
2. **Generate missing required tags** - Derive `seed` if missing using the legacy event's `created_at`
3. **Remove deprecated tags** - Filter out all tags in the deprecated list
4. **Repair visual tags** - Regenerate from seed if missing (these are regenerable)
5. **Do NOT invent personality tags** - If `personality`, `trait`, etc. don't exist, leave them empty
---
## Validation Rules
A valid Blobbi event MUST have:
- `d` tag in canonical format
- `b` tag = `blobbi:ecosystem:v1`
- `t` tag = `blobbi`
- `name` tag (non-empty)
- `seed` tag (64 hex chars)
- `stage` tag (valid value)
- `state` tag (valid value)
- `last_interaction` tag (valid timestamp)
---
## Implementation Checklist
When implementing any flow that modifies Blobbi tags:
- [ ] Start from `canonical.allTags` as the base
- [ ] Remove only task-specific tags (`task`, `task_completed`, `state_started_at`)
- [ ] Preserve ALL persistent tags (identity, visual, personality, progression, social, extension)
- [ ] Filter out deprecated tags
- [ ] Update only the tags that need to change
- [ ] Validate required tags are present
+8 -1
View File
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
import customRules from "./eslint-rules/index.js";
export default tseslint.config(
{ ignores: ["dist", "android"] },
{ ignores: ["dist", "android", "ios"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
@@ -39,6 +39,13 @@ export default tseslint.config(
},
],
"custom/no-placeholder-comments": "error",
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.object.type='MetaProperty'][callee.property.name='glob']",
"message": "import.meta.glob is Vite-only and breaks other bundlers. Inline the assets or use standard imports instead.",
},
],
"no-warning-comments": [
"error",
{ terms: ["fixme"] },
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<title>Ditto — Your content. Your vibe. Your rules.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
<!-- Open Graph -->
@@ -23,7 +23,7 @@
<meta name="twitter:description" content="Your content. Your vibe. Your rules." />
<meta name="twitter:image" content="https://ditto.pub/og-image.jpg" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="theme-color" content="#161b2e" media="(prefers-color-scheme: dark)">
+20 -2
View File
@@ -15,6 +15,9 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -28,6 +31,10 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -63,11 +70,15 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -145,6 +156,7 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -156,6 +168,8 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -295,15 +309,17 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GZLTTH5DLM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.1.1;
MARKETING_VERSION = 2.6.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -317,15 +333,17 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GZLTTH5DLM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.1.1;
MARKETING_VERSION = 2.6.6;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+11
View File
@@ -0,0 +1,11 @@
<?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>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:ditto.pub</string>
<string>webcredentials:ditto.pub?mode=developer</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
@@ -0,0 +1,9 @@
import UIKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
}
}
+8
View File
@@ -47,5 +47,13 @@
</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>NSCameraUsageDescription</key>
<string>Ditto needs camera access to take photos and videos for your posts.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Ditto needs access to your microphone to record voice messages.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</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>
+541
View File
@@ -0,0 +1,541 @@
import Foundation
import Capacitor
import WebKit
// MARK: - Plugin
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
///
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
/// and forwarded to the JS layer as fetch events the same protocol
/// iframe.diy uses. This lets the existing React code serve files identically.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
]
/// Active sandbox instances, keyed by sandbox ID.
private var sandboxes: [String: SandboxInstance] = [:]
// MARK: - Plugin Methods
@objc func create(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
if sandboxes[sandboxId] != nil {
call.reject("Sandbox already exists: \(sandboxId)")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
let sandbox = SandboxInstance(
id: sandboxId,
frame: webViewFrame,
plugin: self
)
self.sandboxes[sandboxId] = sandbox
// Add the container (WebView + spinner overlay) on top of
// the Capacitor WebView.
if let bridge = self.bridge,
let webView = bridge.webView {
webView.superview?.addSubview(sandbox.containerView)
}
call.resolve()
}
}
@objc func navigate(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.navigateToApp()
call.resolve()
}
}
@objc func updateFrame(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
call.resolve()
}
}
@objc func respondToFetch(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let requestId = call.getString("requestId") else {
call.reject("Missing required parameter: requestId")
return
}
guard let response = call.getObject("response") else {
call.reject("Missing required parameter: response")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.schemeHandler.resolveRequest(
requestId: requestId,
status: response["status"] as? Int ?? 200,
statusText: response["statusText"] as? String ?? "OK",
headers: response["headers"] as? [String: String] ?? [:],
bodyBase64: response["body"] as? String
)
call.resolve()
}
@objc func postMessage(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let message = call.getObject("message") else {
call.reject("Missing required parameter: message")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
DispatchQueue.main.async {
sandbox.postMessageToWebView(message)
}
call.resolve()
}
@objc func destroy(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
sandbox.containerView.removeFromSuperview()
sandbox.schemeHandler.cancelAll()
}
call.resolve()
}
}
// MARK: - Event Forwarding
/// Forward a fetch request from the native WebView to JS.
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
/// Forward a script message from the sandbox to JS.
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
notifyListeners("scriptMessage", data: [
"id": sandboxId,
"message": message,
])
}
}
// MARK: - SandboxInstance
/// Manages a single sandboxed WKWebView instance.
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let id: String
let webView: WKWebView
let schemeHandler: SandboxSchemeHandler
private weak var plugin: SandboxPlugin?
private let customScheme: String
/// Container view that holds the WebView and spinner overlay.
let containerView: UIView
/// Native spinner overlay, removed when the first page finishes loading.
private var spinnerOverlay: UIView?
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
self.id = id
self.plugin = plugin
// Each sandbox gets a unique custom URL scheme so that WKWebView
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
self.customScheme = "sbx-\(id)"
self.schemeHandler = SandboxSchemeHandler(
sandboxId: id,
scheme: self.customScheme,
plugin: plugin
)
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
// Add a script message handler for communication from injected scripts.
let userContentController = WKUserContentController()
// Inject a bridge script that:
// 1. Provides window.parent.postMessage()-like functionality
// 2. Routes messages through the native bridge
let bridgeScript = WKUserScript(
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
userContentController.addUserScript(bridgeScript)
config.userContentController = userContentController
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.defaultWebpagePreferences.allowsContentJavaScript = true
// Container view that holds the WebView + spinner overlay.
self.containerView = UIView(frame: frame)
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.webView.isOpaque = false
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
self.webView.scrollView.bounces = false
self.containerView.addSubview(self.webView)
// Dark overlay behind the spinner.
let overlay = UIView(frame: containerView.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.containerView.addSubview(overlay)
// Native spinner uses UIActivityIndicatorView which animates on
// the render thread independently of JS/main-thread work.
let spinner = UIActivityIndicatorView(style: .medium)
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.startAnimating()
overlay.addSubview(spinner)
NSLayoutConstraint.activate([
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
])
self.spinnerOverlay = overlay
super.init()
// Register the message handler and navigation delegate after super.init().
userContentController.add(self, name: "sandboxBridge")
self.webView.navigationDelegate = self
}
/// Navigate the WebView to the sandbox's entry point.
func navigateToApp() {
let initialURL = URL(string: "\(customScheme)://app/index.html")!
webView.load(URLRequest(url: initialURL))
}
/// Remove the native loading overlay. Safe to call multiple times.
func hideSpinner() {
spinnerOverlay?.removeFromSuperview()
spinnerOverlay = nil
}
/// Post a JSON-RPC message to injected scripts inside the WebView.
func postMessageToWebView(_ message: [String: Any]) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return
}
let js = """
(function() {
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
window.__sandboxBridge.onMessage(\(jsonString));
}
})();
"""
webView.evaluateJavaScript(js, completionHandler: nil)
}
// MARK: - WKScriptMessageHandler
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "sandboxBridge",
let body = message.body as? [String: Any] else {
return
}
plugin?.emitScriptMessage(sandboxId: id, message: body)
}
// MARK: - WKNavigationDelegate
/// Remove the spinner overlay once the first page finishes loading.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
hideSpinner()
}
// MARK: - Bridge Script
/// JavaScript injected at document start that provides:
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
/// - `window.addEventListener("message", ...)` support for injected scripts
private static func bridgeScript(scheme: String) -> String {
return """
(function() {
'use strict';
// Message listeners registered by injected scripts.
var messageListeners = [];
// Bridge object for native communication.
window.__sandboxBridge = {
onMessage: function(data) {
// Dispatch to all registered message listeners.
var event = {
data: data,
origin: '\(scheme)://app',
source: window.parent,
type: 'message'
};
for (var i = 0; i < messageListeners.length; i++) {
try {
messageListeners[i](event);
} catch (e) {
console.error('[SandboxBridge] Listener error:', e);
}
}
}
};
// Override addEventListener to capture "message" listeners.
var originalAddEventListener = window.addEventListener;
window.addEventListener = function(type, listener, options) {
if (type === 'message' && typeof listener === 'function') {
messageListeners.push(listener);
}
return originalAddEventListener.call(window, type, listener, options);
};
var originalRemoveEventListener = window.removeEventListener;
window.removeEventListener = function(type, listener, options) {
if (type === 'message') {
var idx = messageListeners.indexOf(listener);
if (idx !== -1) messageListeners.splice(idx, 1);
}
return originalRemoveEventListener.call(window, type, listener, options);
};
// Emulate window.parent.postMessage for scripts that use it
// (e.g. the webxdc bridge script, preview injected script).
if (!window.parent || window.parent === window) {
window.parent = {};
}
window.parent.postMessage = function(data, targetOrigin, transfer) {
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
try {
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
} catch (e) {
console.error('[SandboxBridge] postMessage failed:', e);
}
}
};
})();
""";
}
}
// MARK: - SandboxSchemeHandler
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
/// URL scheme and forwards them to the JS layer as fetch events.
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
private let sandboxId: String
private let scheme: String
private weak var plugin: SandboxPlugin?
/// Pending scheme tasks waiting for a response from JS.
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
private var pendingTasks: [String: WKURLSchemeTask] = [:]
private let lock = NSLock()
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
self.sandboxId = sandboxId
self.scheme = scheme
self.plugin = plugin
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let request = urlSchemeTask.request
guard let url = request.url else {
urlSchemeTask.didFailWithError(NSError(
domain: "SandboxPlugin", code: -1,
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
))
return
}
let requestId = UUID().uuidString
lock.lock()
pendingTasks[requestId] = urlSchemeTask
lock.unlock()
// Serialise the request for the fetch event.
// Rewrite the URL so it looks like a normal HTTP URL to the parent
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
// The JS side only cares about the pathname.
var headers: [String: String] = [:]
if let allHeaders = request.allHTTPHeaderFields {
headers = allHeaders
}
var bodyBase64: String? = nil
if let bodyData = request.httpBody {
bodyBase64 = bodyData.base64EncodedString()
}
let path = url.path.isEmpty ? "/" : url.path
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
let serialisedRequest: [String: Any] = [
"url": rewrittenURL,
"method": request.httpMethod ?? "GET",
"headers": headers,
"body": bodyBase64 as Any,
]
plugin?.emitFetchRequest(
sandboxId: sandboxId,
requestId: requestId,
request: serialisedRequest
)
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// Remove the task from pending JS response will be ignored if it arrives later.
lock.lock()
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
if let key = removed?.key {
pendingTasks.removeValue(forKey: key)
}
lock.unlock()
}
/// Called by the plugin when JS responds to a fetch request.
func resolveRequest(
requestId: String,
status: Int,
statusText: String,
headers: [String: String],
bodyBase64: String?
) {
lock.lock()
guard let task = pendingTasks.removeValue(forKey: requestId) else {
lock.unlock()
return
}
lock.unlock()
// Decode the base64 body.
var bodyData: Data? = nil
if let b64 = bodyBase64 {
bodyData = Data(base64Encoded: b64)
}
// Build the response.
// Use the task's original URL for the response.
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
let response = HTTPURLResponse(
url: responseURL,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
DispatchQueue.main.async {
task.didReceive(response)
if let data = bodyData {
task.didReceive(data)
}
task.didFinish()
}
}
/// Cancel all pending tasks (called on destroy).
func cancelAll() {
lock.lock()
let tasks = pendingTasks
pendingTasks.removeAll()
lock.unlock()
for (_, task) in tasks {
task.didFailWithError(NSError(
domain: "SandboxPlugin", code: -999,
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
))
}
}
}
+8 -2
View File
@@ -14,9 +14,12 @@ let package = Package(
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
],
targets: [
.target(
@@ -26,9 +29,12 @@ let package = Package(
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
]
)
]
+4404 -540
View File
File diff suppressed because it is too large Load Diff
+48 -25
View File
@@ -1,26 +1,28 @@
{
"name": "ditto",
"private": true,
"version": "2.1.1",
"version": "2.6.6",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
"icons": "bash scripts/generate-icons.sh"
},
"engines": {
"npm": "10.9.4",
"node": "22.x"
"node": ">=22"
},
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/haptics": "^8.0.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
"@capgo/capacitor-autofill-save-password": "^8.0.22",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -45,6 +47,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",
@@ -53,8 +56,19 @@
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.3.1",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
"@milkdown/plugin-clipboard": "^7.20.0",
"@milkdown/plugin-history": "^7.20.0",
"@milkdown/plugin-listener": "^7.20.0",
"@milkdown/plugin-upload": "^7.20.0",
"@milkdown/preset-commonmark": "^7.20.0",
"@milkdown/preset-gfm": "^7.20.0",
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -63,18 +77,18 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -83,13 +97,14 @@
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -99,15 +114,16 @@
"emoji-mart": "^5.6.0",
"fflate": "^0.8.2",
"hls.js": "^1.6.15",
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react": "^19.2.4",
"react-blurhash": "^0.3.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.71.1",
"react-intersection-observer": "^9.16.0",
@@ -116,11 +132,12 @@
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"slugify": "^1.6.8",
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uri-templates": "^0.2.0",
"vaul": "^0.9.3",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -131,13 +148,14 @@
"@html-eslint/eslint-plugin": "^0.41.0",
"@html-eslint/parser": "^0.41.0",
"@tailwindcss/typography": "^0.5.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.5.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"@webxdc/types": "^2.1.2",
@@ -148,10 +166,15 @@
"globals": "^15.9.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^8.0.0",
"vite": "^8.0.3",
"vitest": "^3.1.4"
},
"overrides": {
"react": "$react",
"react-dom": "$react-dom"
}
}
@@ -0,0 +1,7 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.ditto.app"
]
}
}
+14 -7
View File
@@ -1,8 +1,15 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "pub.ditto.app",
"sha256_cert_fingerprints": ["7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71"]
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "pub.ditto.app",
"sha256_cert_fingerprints": [
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
]
}
}
}]
]
+362
View File
@@ -1,5 +1,367 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
### Fixed
- Custom emoji no longer stretch to fill their container
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
## [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 now display in feeds and detail pages with hero images, icons, 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 login flows with Amber, Primal, and other signing apps
### 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
- 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 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
- Event links now route correctly for all event types
- 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
- New posts no longer cause scroll jumps -- they buffer while you're reading and appear with a tap
- Mobile header no longer shows double-layered backgrounds on notched devices
- Pinned tabs stay properly positioned when scrolling on mobile
- Signer approval toasts no longer fire in rapid succession on unstable connections
- Toasts are easier to swipe away on mobile
- Content warnings now blur thumbnails in the media grid
## [2.2.0] - 2026-03-28
### Added
- Blobbi virtual pets -- adopt an egg, hatch it, evolve it into one of 16 adult forms, and care for it with feeding, cleaning, medicine, music, and singing
- 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 divines experience on both mobile and desktop with floating controls
- 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
- Remote signer UX improvements for Amber users on Android
- Badge awards now trigger push notifications
- Badges display in profile bio section with a "Give badge" option in the profile menu
### Changed
- Notification "Mentions" tab now shows only pure mentions, filtering out replies
- Notification preferences ("only from people I follow") now properly apply to push notifications, native Android notifications, and the unread dot
- Upgraded from React 18 to React 19
- Reduced initial bundle size by ~50% with improved code splitting and lazy loading
### Fixed
- Zapping Primal users no longer produces an error
- Hashtag feeds now match case-insensitively for parity with search results
- Mobile top bar arc no longer lingers on pages without tabs
- Give Badge dialog and profile menu action handlers
## [2.1.1] - 2026-03-27
### Added
+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);
});
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
*
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
* only, so local plugins compiled directly into the app binary (like
* SandboxPlugin) are not included. This script appends them after sync so
* the Capacitor bridge eagerly registers them at startup.
*
* Usage: node scripts/patch-cap-config.mjs
* Typically run after `npx cap sync`.
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
/** Local plugin class names to ensure are registered. */
const LOCAL_PLUGINS = ['SandboxPlugin'];
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
for (const platform of platforms) {
const configPath = resolve(platform, 'capacitor.config.json');
let config;
try {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
// Platform may not exist or config not yet generated — skip.
continue;
}
const classList = new Set(config.packageClassList ?? []);
let changed = false;
for (const plugin of LOCAL_PLUGINS) {
if (!classList.has(plugin)) {
classList.add(plugin);
changed = true;
}
}
if (changed) {
config.packageClassList = [...classList];
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
}
}
+58 -25
View File
@@ -1,8 +1,7 @@
// NOTE: This file should normally not be modified unless you are adding a new provider.
// To add new routes, edit the AppRouter.tsx file.
import { Capacitor } from "@capacitor/core";
import { StatusBar, Style } from "@capacitor/status-bar";
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
import { NostrLoginProvider } from "@nostrify/react/login";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
@@ -16,15 +15,20 @@ 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 { secureStorage } from "@/lib/secureStorage";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
const dmConfig: DMConfig = {
enabled: true,
enabled: false,
protocolMode: PROTOCOL_MODE.NIP04_OR_NIP17,
};
@@ -47,6 +51,7 @@ const hardcodedConfig: AppConfig = {
appName: "Ditto",
appId: "ditto",
homePage: "feed",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
autoShareTheme: true,
@@ -57,7 +62,9 @@ const hardcodedConfig: AppConfig = {
},
feedSettings: {
feedIncludePosts: true,
feedIncludeComments: true,
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeArticles: true,
showArticles: true,
showEvents: true,
@@ -110,18 +117,18 @@ const hardcodedConfig: AppConfig = {
feedIncludeBadgeDefinitions: true,
feedIncludeProfileBadges: true,
feedIncludeVanish: true,
feedIncludeBlobbi: true,
followsFeedShowReplies: true,
},
sidebarOrder: [
"feed",
"notifications",
"search",
"bookmarks",
"profile",
"photos",
"videos",
"blobbi",
"badges",
"emojis",
"letters",
"themes",
"theme",
"settings",
"help",
],
@@ -142,28 +149,52 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
{ id: 'wikipedia' },
],
};
/**
* 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() {
useNsecPasteGuard();
useEffect(() => {
// Initialize StatusBar for mobile apps
// Initialize system bars for mobile apps.
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
// setOverlaysWebView / setBackgroundColor no longer work. The new
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
if (Capacitor.isNativePlatform()) {
StatusBar.setStyle({ style: Style.Dark }).catch(() => {
// StatusBar may not be available on all platforms
});
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {
// Ignore errors on unsupported platforms
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
// SystemBars may not be available on all platforms
});
}
}, []);
@@ -174,18 +205,20 @@ export function App() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login">
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
<EmotionDevProvider>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</NWCProvider>
</NostrProvider>
+111 -57
View File
@@ -1,62 +1,84 @@
import { useState } from "react";
import { lazy, Suspense, useState } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AudioNavigationGuard } from "@/components/AudioNavigationGuard";
import { DeepLinkHandler } from "@/components/DeepLinkHandler";
import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { ReplyComposeModal } from "@/components/ReplyComposeModal";
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";
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
import { AIChatPage } from "./pages/AIChatPage";
import { BadgesPage } from "./pages/BadgesPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import { BooksPage } from "./pages/BooksPage";
import { ContentPage } from "./pages/ContentPage";
import { ContentSettingsPage } from "./pages/ContentSettingsPage";
import { DomainFeedPage } from "./pages/DomainFeedPage";
import { EventsFeedPage } from "./pages/EventsFeedPage";
import { ExternalContentPage } from "./pages/ExternalContentPage";
import { GeotagPage } from "./pages/GeotagPage";
import { HashtagPage } from "./pages/HashtagPage";
import { HelpPage } from "./pages/HelpPage";
import { HomePage } from "./pages/HomePage";
// Critical-path pages: eagerly loaded (landing + fallback)
import Index from "./pages/Index";
import { KindFeedPage } from "./pages/KindFeedPage";
import { MagicSettingsPage } from "./pages/MagicSettingsPage";
import { MusicFeedPage } from "./pages/MusicFeedPage";
import { NetworkSettingsPage } from "./pages/NetworkSettingsPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
import { NotificationSettings } from "./pages/NotificationSettings";
import { NotificationsPage } from "./pages/NotificationsPage";
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
import { PodcastsFeedPage } from "./pages/PodcastsFeedPage";
import { CSAEPolicyPage } from "./pages/CSAEPolicyPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { ProfileSettings } from "./pages/ProfileSettings";
import { RelayPage } from "./pages/RelayPage";
import { SearchPage } from "./pages/SearchPage";
import { SettingsPage } from "./pages/SettingsPage";
import { ThemesPage } from "./pages/ThemesPage";
import { TreasuresPage } from "./pages/TreasuresPage";
import { TrendsPage } from "./pages/TrendsPage";
import { UserListsPage } from "./pages/UserListsPage";
import { VideosFeedPage } from "./pages/VideosFeedPage";
import { VinesFeedPage } from "./pages/VinesFeedPage";
import { WalletSettingsPage } from "./pages/WalletSettingsPage";
import { WebxdcFeedPage } from "./pages/WebxdcFeedPage";
import { WorldPage } from "./pages/WorldPage";
import { ArchivePage } from "./pages/ArchivePage";
import { BlueskyPage } from "./pages/BlueskyPage";
import { ChangelogPage } from "./pages/ChangelogPage";
import { WikipediaPage } from "./pages/WikipediaPage";
import { LettersPage } from "./pages/LettersPage";
import { LetterPreferencesPage } from "./pages/LetterPreferencesPage";
// Lazy-loaded companion layer (~450K code-split)
const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => ({ default: m.BlobbiCompanionLayer })));
// 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 })));
// All other pages: code-split via React.lazy
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
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 })));
const MusicFeedPage = lazy(() => import("./pages/MusicFeedPage").then(m => ({ default: m.MusicFeedPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const ThemesPage = lazy(() => import("./pages/ThemesPage").then(m => ({ default: m.ThemesPage })));
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
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 FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -77,7 +99,31 @@ function PollsFeedPage() {
icon={sidebarItemIcon("polls", "size-5")}
onFabClick={() => setComposeOpen(true)}
/>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
{composeOpen && (
<Suspense fallback={null}>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} initialMode="poll" />
</Suspense>
)}
</>
);
}
/** 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>
)}
</>
);
}
@@ -94,11 +140,21 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
<ScrollToTop />
<BlobbiActionsProvider>
<Suspense fallback={null}>
<BlobbiCompanionLayer />
</Suspense>
</BlobbiActionsProvider>
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
@@ -160,6 +216,8 @@ export function AppRouter() {
}
/>
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route path="/articles/new" element={<ArticleEditorPage />} />
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
<Route
path="/articles"
element={
@@ -167,6 +225,7 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
@@ -180,16 +239,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={
@@ -207,6 +257,7 @@ export function AppRouter() {
<Route path="/themes" element={<ThemesPage />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/blobbi" element={<BlobbiPage />} />
<Route path="/world" element={<WorldPage />} />
<Route path="/badges" element={<BadgesPage />} />
<Route path="/books" element={<BooksPage />} />
@@ -214,6 +265,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 />} />
@@ -226,6 +278,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 */}
@@ -0,0 +1,317 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import {
filterInventoryByAction,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
canUseAction,
getStageRestrictionMessage,
ACTION_METADATA,
type InventoryAction,
type ResolvedInventoryItem,
type EggStatPreview,
} from '../lib/blobbi-action-utils';
interface BlobbiActionInventoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
}
export function BlobbiActionInventoryModal({
open,
onOpenChange,
action,
companion,
profile: _profile,
onUseItem,
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
const stageMessage = getStageRestrictionMessage(companion, action);
const isEmpty = availableItems.length === 0;
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
onUseItem(item.itemId);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md 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-primary/20 to-primary/5 flex items-center justify-center text-xl sm:text-2xl shrink-0">
{actionMeta.icon}
</div>
<div className="min-w-0">
<DialogTitle className="text-lg sm:text-xl">{actionMeta.label}</DialogTitle>
<p className="text-xs sm:text-sm text-muted-foreground truncate">
{actionMeta.description}
</p>
</div>
</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 px-4 sm:px-6 py-3 sm:py-4">
{/* Stage Restriction Message */}
{!canUse && stageMessage && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-amber-500/10 flex items-center justify-center mb-4">
<span className="text-3xl">🥚</span>
</div>
<h3 className="text-lg font-semibold mb-2">Not Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
{stageMessage}
</p>
</div>
)}
{/* Empty State */}
{canUse && isEmpty && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for this action at your Blobbi's current stage.
</p>
</div>
)}
{/* Item List */}
{canUse && !isEmpty && (
<div className="grid gap-3">
{availableItems.map((item) => (
<BlobbiInventoryUseRow
key={item.itemId}
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ─── Inventory Use Row ────────────────────────────────────────────────────────
interface BlobbiInventoryUseRowProps {
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
onUse: () => void;
isUsing: boolean;
disabled: boolean;
}
function BlobbiInventoryUseRow({
item,
companion,
action,
onUse,
isUsing,
disabled,
}: BlobbiInventoryUseRowProps) {
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
{ hygiene: companion.stats.hygiene, happiness: companion.stats.happiness },
item.effect
),
};
}
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
};
}, [companion.stats, item.effect, isEgg, isMedicine, isClean]);
const hasChanges = normalStatChanges.length > 0 || eggStatChanges.length > 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm hover:border-primary/30 transition-colors">
{/* Top row on mobile: Icon + Info + 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="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.icon}
</div>
</div>
{/* Item Info */}
<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>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
</div>
{/* Use Button */}
<Button
size="sm"
onClick={onUse}
disabled={disabled}
className="shrink-0"
>
{isUsing ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
</div>
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
className={cn(
'font-medium',
delta > 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400'
)}
>
{delta > 0 ? '+' : ''}
{delta}
</span>{' '}
<span className="text-muted-foreground capitalize">
{stat.replace('_', ' ')}
</span>
</span>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,201 @@
// src/blobbi/actions/components/BlobbiActionsModal.tsx
import { Loader2, Moon, Sun, Utensils, Gamepad2, Sparkles as SparklesIcon, Pill, Music, Mic, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { InventoryAction, DirectAction } from '../lib/blobbi-action-utils';
interface BlobbiActionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
onRest: () => void;
onInventoryAction: (action: InventoryAction) => void;
onDirectAction: (action: DirectAction) => void;
actionInProgress: string | null;
isPublishing: boolean;
}
export function BlobbiActionsModal({
open,
onOpenChange,
companion,
onRest,
onInventoryAction,
onDirectAction,
actionInProgress,
isPublishing,
}: BlobbiActionsModalProps) {
const isSleeping = companion.state === 'sleeping';
const isDisabled = isPublishing || actionInProgress !== null;
const isEgg = companion.stage === 'egg';
const handleAction = (action: () => void) => {
action();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm 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-4">
<div>
<DialogTitle>Blobbi Actions</DialogTitle>
<p className="text-sm text-muted-foreground">{companion.name}</p>
</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">
<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 px-4 sm:px-6 py-3 sm:py-4">
<div className="grid gap-3">
{/* Feed Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('feed'))}
disabled={isDisabled}
>
<Utensils className="size-5 text-orange-500" />
<div className="text-left">
<p className="font-medium">Feed</p>
<p className="text-xs text-muted-foreground">
Give your Blobbi something to eat
</p>
</div>
</Button>
)}
{/* Play Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('play'))}
disabled={isDisabled}
>
<Gamepad2 className="size-5 text-yellow-500" />
<div className="text-left">
<p className="font-medium">Play</p>
<p className="text-xs text-muted-foreground">
Play with toys to make your Blobbi happy
</p>
</div>
</Button>
)}
{/* Clean Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('clean'))}
disabled={isDisabled}
>
<SparklesIcon className="size-5 text-blue-500" />
<div className="text-left">
<p className="font-medium">Clean</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg clean and fresh'
: 'Keep your Blobbi clean and fresh'}
</p>
</div>
</Button>
{/* Medicine Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onInventoryAction('medicine'))}
disabled={isDisabled}
>
<Pill className="size-5 text-green-500" />
<div className="text-left">
<p className="font-medium">Medicine</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Keep your egg healthy'
: 'Heal your Blobbi'}
</p>
</div>
</Button>
{/* Play Music Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('play_music'))}
disabled={isDisabled}
>
<Music className="size-5 text-pink-500" />
<div className="text-left">
<p className="font-medium">Play Music</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Play soothing music for your egg'
: 'Play music for your Blobbi'}
</p>
</div>
</Button>
{/* Sing Action - available for all stages */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(() => onDirectAction('sing'))}
disabled={isDisabled}
>
<Mic className="size-5 text-purple-500" />
<div className="text-left">
<p className="font-medium">Sing</p>
<p className="text-xs text-muted-foreground">
{isEgg
? 'Sing a lullaby to your egg'
: 'Sing to your Blobbi'}
</p>
</div>
</Button>
{/* Sleep/Wake Action - hidden for eggs */}
{!isEgg && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-14"
onClick={() => handleAction(onRest)}
disabled={isDisabled}
>
{actionInProgress === 'rest' ? (
<Loader2 className="size-5 animate-spin" />
) : isSleeping ? (
<Sun className="size-5 text-amber-500" />
) : (
<Moon className="size-5 text-violet-500" />
)}
<div className="text-left">
<p className="font-medium">{isSleeping ? 'Wake Up' : 'Sleep'}</p>
<p className="text-xs text-muted-foreground">
{isSleeping ? 'Wake your Blobbi up' : 'Put your Blobbi to sleep'}
</p>
</div>
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,538 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* Missions modal for Blobbi — card-grid quest board.
*
* Layout:
* 1. Sticky header with title, subtitle, legend help button, close
* 2. Current Focus section (hatch / evolve) — collapsible, default open
* 3. Daily Bounties section — collapsible, default open
* 4. Settings row — low emphasis toggle (not collapsible)
*
* Both main sections use lightweight Radix Collapsible wrappers.
* Collapsed headers still show summary info (progress / coins).
*/
import {
Loader2,
XCircle,
AlertTriangle,
X,
Eye,
Scroll,
Compass,
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useState } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
import { TasksPanel } from './TasksPanel';
import { DailyMissionsPanel } from './DailyMissionsPanel';
import { useDailyMissions } from '../hooks/useDailyMissions';
import { useRerollMission } from '../hooks/useRerollMission';
// ─── Types ────────────────────────────────────────────────────────────────────
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
hatchTasks: HatchTasksResult;
evolveTasks: EvolveTasksResult;
onOpenPostModal: () => void;
onHatch: () => void;
isHatching: boolean;
onEvolve: () => void;
isEvolving: boolean;
onStopIncubation: () => Promise<void>;
isStoppingIncubation: boolean;
onStopEvolution: () => Promise<void>;
isStoppingEvolution: boolean;
availableStages?: ('egg' | 'baby' | 'adult')[];
showMissionCard?: boolean;
onToggleMissionCard?: (visible: boolean) => void;
}
// ─── Section Chevron ─────────────────────────────────────────────────────────
function SectionChevron({ open }: { open: boolean }) {
return (
<ChevronDown
className={cn(
'size-4 text-muted-foreground/60 transition-transform duration-200',
open && 'rotate-180',
)}
/>
);
}
// ─── Mission Type Legend ──────────────────────────────────────────────────────
function MissionTypeLegend() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
aria-label="Mission types legend"
>
<HelpCircle className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56 p-3">
<p className="text-xs font-semibold mb-2">Mission Types</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
<Scroll className="size-3 text-amber-500" />
</div>
<div>
<p className="text-xs font-medium">Daily Bounty</p>
<p className="text-[10px] text-muted-foreground">Resets every day</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🥚</span>
</div>
<div>
<p className="text-xs font-medium">Hatch Task</p>
<p className="text-[10px] text-muted-foreground">Egg progression</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs">🐣</span>
</div>
<div>
<p className="text-xs font-medium">Evolve Task</p>
<p className="text-[10px] text-muted-foreground">Baby progression</p>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
// ─── Daily Missions Section ───────────────────────────────────────────────────
interface DailyMissionsSectionProps {
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({
availableStages,
disabled,
defaultOpen = true,
}: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayXp,
allComplete,
bonusUnlocked,
bonusXp,
noMissionsAvailable,
rerollsRemaining,
} = useDailyMissions({ availableStages });
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const completedCount = missions.filter((m) => m.complete).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
<h3 className="font-semibold text-sm">Daily Bounties</h3>
</div>
<div className="flex items-center gap-2">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
{completedCount} / {missions.length}
</span>
{allComplete && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
</span>
)}
</div>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayXp={todayXp}
disabled={disabled || isRerolling}
bonusUnlocked={bonusUnlocked}
bonusXp={bonusXp}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Stop Process Confirmation Dialog ─────────────────────────────────────────
interface StopConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companionName: string;
processType: 'incubation' | 'evolution';
onConfirm: () => Promise<void>;
isPending: boolean;
}
function StopConfirmationDialog({
open,
onOpenChange,
companionName,
processType,
onConfirm,
isPending,
}: StopConfirmationDialogProps) {
const handleConfirm = async () => {
await onConfirm();
onOpenChange(false);
};
const label = processType === 'incubation' ? 'Incubation' : 'Evolution';
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Stop {label}?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
Are you sure you want to stop {processType === 'incubation' ? 'incubating' : 'evolving'}{' '}
<strong>{companionName}</strong>?
</p>
<p>
This will interrupt the {processType} process and clear all task progress.
You can restart {processType} later, but you'll need to complete the tasks again.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending}
className="bg-destructive hover:bg-destructive/90"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
`Stop ${label}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
interface CurrentFocusSectionProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting: boolean;
onStop: () => Promise<void>;
isStopping: boolean;
defaultOpen?: boolean;
}
function CurrentFocusSection({
companion,
tasks,
processType,
onOpenPostModal,
onComplete,
isCompleting,
onStop,
isStopping,
defaultOpen = true,
}: CurrentFocusSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
const completeEmoji = isIncubation ? '🐣' : '';
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
const badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
const completedCount = tasks.tasks.filter((t) => t.completed).length;
const totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={cn(
'text-xs font-semibold px-2 py-0.5',
isIncubation
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
)}
>
{badgeLabel}
</Badge>
<span className="text-sm font-semibold">{title}</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'text-xs font-medium tabular-nums',
tasks.allCompleted
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
>
{completedCount} / {totalTasks}
</span>
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
{/* Task card grid */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
category={category}
/>
{/* Stop process — low emphasis */}
<div className="mt-3 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
>
{isStopping ? (
<>
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-3.5 mr-1.5" />
{stopLabel}
</>
)}
</Button>
</div>
</div>
</CollapsibleContent>
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
companionName={companion.name}
processType={processType}
onConfirm={onStop}
isPending={isStopping}
/>
</Collapsible>
);
}
// ─── Empty Focus State ────────────────────────────────────────────────────────
function EmptyFocusState() {
return (
<div className="py-6 text-center">
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No active progression right now</p>
</div>
);
}
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function BlobbiMissionsModal({
open,
onOpenChange,
companion,
hatchTasks,
evolveTasks,
onOpenPostModal,
onHatch,
isHatching,
onEvolve,
isEvolving,
onStopIncubation,
isStoppingIncubation,
onStopEvolution,
isStoppingEvolution,
availableStages,
showMissionCard,
onToggleMissionCard,
}: BlobbiMissionsModalProps) {
const isIncubating = companion.state === 'incubating';
const isEvolvingState = companion.state === 'evolving';
const isEgg = companion.stage === 'egg';
const isBaby = companion.stage === 'baby';
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
{/* ── Sticky Header ── */}
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h2 className="text-base font-bold tracking-tight">Missions</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Quests & bounties for {companion.name}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<MissionTypeLegend />
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</div>
</div>
{/* ── Scrollable Content ── */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
{/* 1. Current Focus */}
{hasActiveProcess ? (
<>
{isIncubating && isEgg ? (
<CurrentFocusSection
companion={companion}
tasks={hatchTasks}
processType="incubation"
onOpenPostModal={onOpenPostModal}
onComplete={onHatch}
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
/>
) : isEvolvingState && isBaby ? (
<CurrentFocusSection
companion={companion}
tasks={evolveTasks}
processType="evolution"
onOpenPostModal={onOpenPostModal}
onComplete={onEvolve}
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
/>
) : null}
</>
) : (
<EmptyFocusState />
)}
{/* Divider */}
<div className="h-px bg-border/60" />
{/* 2. Daily Bounties */}
<DailyMissionsSection
availableStages={availableStages}
disabled={isProcessBusy}
/>
{/* 3. Settings */}
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
<>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between py-1">
<Label
htmlFor="mission-card-toggle"
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
>
<Eye className="size-3.5" />
Show mission card on main page
</Label>
<Switch
id="mission-card-toggle"
checked={showMissionCard}
onCheckedChange={onToggleMissionCard}
/>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,294 @@
// src/blobbi/actions/components/BlobbiPostModal.tsx
/**
* Modal for creating a Blobbi post (hatch or evolve).
*
* Requirements:
* - Prefilled with stage-aware text:
* - Hatch: "Hello Nostr! Posting to hatch #<blobbiName> #blobbi #ditto #nostr"
* - Evolve: "Hello Nostr! Posting to evolve #<blobbiName> #blobbi #ditto #nostr"
* - User can ADD text but CANNOT delete the prefix or required hashtags
* - Blobbi name is sanitized into a valid hashtag format
* - Enforced programmatically
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { X, Loader2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
buildHatchPhrase,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The process type for the post */
export type BlobbiPostProcess = 'hatch' | 'evolve';
interface BlobbiPostModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's name (will be converted to hashtag) */
blobbiName: string;
/** The process type - 'hatch' for incubation, 'evolve' for evolution */
process?: BlobbiPostProcess;
onSuccess?: () => void;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Build the required prefix text based on process type.
*/
function buildPrefix(process: BlobbiPostProcess): string {
return process === 'evolve'
? 'Posting to evolve'
: 'Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function BlobbiPostModal({
open,
onOpenChange,
blobbiName,
process = 'hatch',
onSuccess,
}: BlobbiPostModalProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const prefix = useMemo(() => buildPrefix(process), [process]);
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
// The required phrase that must appear in the post
const requiredPhrase = useMemo(() =>
process === 'hatch'
? buildHatchPhrase(blobbiName)
: `${prefix} ${capitalizedName} #blobbi`,
[process, blobbiName, prefix, capitalizedName]
);
// Build default content (the phrase itself is enough)
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
const [content, setContent] = useState(defaultContent);
const [validationError, setValidationError] = useState<string | null>(null);
// Reset content when modal opens or props change
useEffect(() => {
if (open) {
setContent(defaultContent);
setValidationError(null);
}
}, [open, defaultContent]);
/**
* Validate that the content contains the required phrase.
*/
const validateContent = useCallback((text: string): string | null => {
if (!text.includes(requiredPhrase)) {
return `The post must contain: "${requiredPhrase}"`;
}
return null;
}, [requiredPhrase]);
/**
* Handle content change with validation.
* Prevents deletion of required content.
*/
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newContent = e.target.value;
// Allow content changes only if it preserves the required elements
const error = validateContent(newContent);
if (error) {
setValidationError(error);
// Still update content but show error
// This allows the user to see what they're trying to do
// but the post button will be disabled
} else {
setValidationError(null);
}
setContent(newContent);
}, [validateContent]);
/**
* Handle post creation.
*/
const handlePost = useCallback(async () => {
if (!user?.pubkey) {
toast({
title: 'Not logged in',
description: 'Please log in to create a post',
variant: 'destructive',
});
return;
}
// Final validation
const error = validateContent(content);
if (error) {
setValidationError(error);
return;
}
try {
// Build tags for the post: extract all hashtags from content
const tags: string[][] = [];
const seen = new Set<string>();
// Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags
for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) {
const lower = hashtag.toLowerCase();
if (!seen.has(lower)) {
tags.push(['t', lower]);
seen.add(lower);
}
}
// Extract any additional hashtags from the content
const contentHashtags = content.match(/#(\w+)/g) || [];
for (const tag of contentHashtags) {
const tagValue = tag.slice(1).toLowerCase();
if (!seen.has(tagValue)) {
tags.push(['t', tagValue]);
seen.add(tagValue);
}
}
await createEvent({
kind: 1,
content,
tags,
});
toast({
title: 'Post created!',
description: process === 'evolve'
? 'Your Blobbi evolution post has been published.'
: 'Your Blobbi hatch post has been published.',
});
onOpenChange(false);
onSuccess?.();
} catch (error) {
toast({
title: 'Failed to create post',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
const canPost = !validationError && content.trim().length > 0;
const dialogTitle = process === 'evolve' ? 'Blobbi Evolution Post' : 'Blobbi Hatch Post';
const alertText = process === 'evolve'
? "This special post announces your Blobbi's evolution! The highlighted text must remain in your post."
: "This special post announces your Blobbi's hatching journey! The highlighted text must remain in your post.";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0">
{/* Header */}
<div className="flex items-center justify-between px-4 h-14 border-b">
<DialogTitle className="text-base font-semibold">
{dialogTitle}
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Info alert */}
<Alert className="border-primary/20 bg-primary/5">
<AlertDescription className="text-sm">
{alertText}
</AlertDescription>
</Alert>
{/* Textarea */}
<div className="space-y-2">
<Textarea
value={content}
onChange={handleContentChange}
placeholder="Write your post..."
className="min-h-[150px] resize-none"
disabled={isPending}
/>
{/* Character count and validation */}
<div className="flex items-center justify-between text-sm">
<div>
{validationError && (
<span className="text-destructive flex items-center gap-1">
<AlertCircle className="size-3.5" />
{validationError}
</span>
)}
</div>
<span className="text-muted-foreground">
{content.length} characters
</span>
</div>
</div>
{/* Preview of required content */}
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
<p className="text-sm font-medium text-primary">
{requiredPhrase}
</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t bg-muted/30">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
onClick={handlePost}
disabled={!canPost || isPending}
className="min-w-24"
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Posting...
</>
) : (
'Post'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,284 @@
/**
* DailyMissionsPanel — card-grid layout for daily bounties.
*
* Each mission is a compact card in a 2-col grid.
* Tapping a card expands it to show progress and reroll.
* Only one card expanded at a time.
* Completion is implicit (derived from progress vs target).
*/
import { useState } from 'react';
import {
Check,
Sparkles,
Gift,
Egg,
Trophy,
RefreshCw,
Heart,
Utensils,
Droplets,
Moon,
Camera,
Mic,
Music,
Pill,
CircleDot,
Zap,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMissionAction } from '../lib/daily-missions';
import type { DailyMissionView } from '../hooks/useDailyMissions';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
missions: DailyMissionView[];
onRerollMission?: (missionId: string) => void;
todayXp: number;
disabled?: boolean;
bonusUnlocked?: boolean;
bonusXp?: number;
noMissionsAvailable?: boolean;
rerollsRemaining?: number;
isRerolling?: boolean;
}
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
const cls = 'size-5';
switch (action) {
case 'interact':
return <Heart className={cls} />;
case 'feed':
return <Utensils className={cls} />;
case 'clean':
return <Droplets className={cls} />;
case 'sleep':
return <Moon className={cls} />;
case 'take_photo':
return <Camera className={cls} />;
case 'sing':
return <Mic className={cls} />;
case 'play_music':
return <Music className={cls} />;
case 'medicine':
return <Pill className={cls} />;
default:
return <CircleDot className={cls} />;
}
}
// ─── Bonus Card ───────────────────────────────────────────────────────────────
interface BonusCardProps {
isUnlocked: boolean;
xp: number;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
return (
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isUnlocked}
progress={isUnlocked ? 1 : 0}
isExpanded={isExpanded}
onToggle={onToggle}
>
<MissionDescription>
{isUnlocked
? 'Bonus XP for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
<div className="flex items-center gap-1 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
+{formatCompactNumber(xp)} XP
</div>
</ExpandableMissionCard>
);
}
// ─── Empty / Done States ──────────────────────────────────────────────────────
function NoMissionsState() {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Egg className="size-5 text-muted-foreground/50" />
<div>
<p className="text-sm font-medium">Hatch your Blobbi first</p>
<p className="text-xs text-muted-foreground mt-0.5">
Daily missions unlock after hatching
</p>
</div>
</div>
);
}
function AllCompleteState({ todayXp }: { todayXp: number }) {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
<div>
<p className="text-sm font-medium">All done for today</p>
<p className="text-xs text-muted-foreground mt-0.5">
Earned{' '}
<span className="font-medium text-violet-600 dark:text-violet-400">
{formatCompactNumber(todayXp)} XP
</span>{' '}
come back tomorrow!
</p>
</div>
</div>
);
}
// ─── Reroll Counter ───────────────────────────────────────────────────────────
function RerollCounter({ remaining }: { remaining: number }) {
const text =
remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
return (
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
<RefreshCw className="size-2.5" />
<span>{text}</span>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function DailyMissionsPanel({
missions,
onRerollMission,
todayXp,
disabled,
bonusUnlocked = false,
bonusXp = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
if (noMissionsAvailable) return <NoMissionsState />;
const allComplete = missions.every((m) => m.complete);
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{/* Reroll counter */}
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
{/* Regular mission cards */}
{missions.map((mission) => {
const progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
const showReroll = onRerollMission && !mission.complete && canReroll;
return (
<ExpandableMissionCard
key={mission.id}
id={mission.id}
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.complete}
progress={Math.min(progressFrac, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
{/* Description */}
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.complete && (
<MissionProgress
current={mission.progress}
required={mission.target}
completed={mission.complete}
/>
)}
{/* XP + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
{formatCompactNumber(mission.xp)} XP
</span>
{showReroll && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onRerollMission(mission.id);
}}
disabled={disabled || isRerolling}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
>
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{mission.complete && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
</span>
)}
</div>
{/* Complete indicator */}
{mission.complete && (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<Gift className="size-3.5" />
+{formatCompactNumber(mission.xp)} XP earned
</div>
)}
</ExpandableMissionCard>
);
})}
{/* Bonus card */}
<BonusCard
isUnlocked={bonusUnlocked}
xp={bonusXp}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
</div>
);
}
@@ -0,0 +1,250 @@
// src/blobbi/actions/components/ExpandableMissionCard.tsx
/**
* Expandable mission card for the quest-board grid.
*
* Collapsed: compact square-ish card showing icon, title, and a tiny
* progress ring / checkmark.
* Expanded: full-width row that reveals description, progress bar,
* action link, claim button, dynamic hints, etc.
*
* Only one card is expanded at a time per section (controlled by parent).
*/
import type { ReactNode } from 'react';
import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export type MissionCategory = 'daily' | 'hatch' | 'evolve';
export interface ExpandableMissionCardProps {
/** Unique id used to track which card is expanded */
id: string;
/** Mission category for visual styling */
category: MissionCategory;
/** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */
icon: ReactNode;
/** Short title */
title: string;
/** Whether the mission is complete */
completed: boolean;
/** Progress fraction 0-1 (used for the tiny ring in compact mode) */
progress: number;
/** Whether this card is currently expanded */
isExpanded: boolean;
/** Parent calls this to toggle expansion */
onToggle: (id: string) => void;
/** Content rendered only when expanded */
children: ReactNode;
/** Optional extra className on the outer wrapper */
className?: string;
}
// ─── Tiny Progress Ring ───────────────────────────────────────────────────────
function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) {
const size = 28;
const stroke = 2.5;
const radius = (size - stroke) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - progress * circumference;
if (completed) {
return (
<div className="size-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
</div>
);
}
const ringColor =
category === 'hatch'
? 'text-sky-500'
: category === 'evolve'
? 'text-violet-500'
: 'text-amber-500';
return (
<svg width={size} height={size} className={cn('shrink-0 -rotate-90', ringColor)}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
opacity={0.15}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
);
}
// ─── Accent colors per category ───────────────────────────────────────────────
const CATEGORY_STYLES: Record<MissionCategory, { bg: string; expandedBg: string; border: string }> = {
daily: {
bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10',
expandedBg: 'bg-amber-500/[0.06]',
border: 'ring-amber-500/20',
},
hatch: {
bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10',
expandedBg: 'bg-sky-500/[0.06]',
border: 'ring-sky-500/20',
},
evolve: {
bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10',
expandedBg: 'bg-violet-500/[0.06]',
border: 'ring-violet-500/20',
},
};
// ─── Component ────────────────────────────────────────────────────────────────
export function ExpandableMissionCard({
id,
category,
icon,
title,
completed,
progress,
isExpanded,
onToggle,
children,
className,
}: ExpandableMissionCardProps) {
const styles = CATEGORY_STYLES[category];
// ── Collapsed card ──
if (!isExpanded) {
return (
<button
type="button"
onClick={() => onToggle(id)}
className={cn(
'flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all text-center cursor-pointer select-none',
'ring-1 ring-transparent',
completed ? 'bg-emerald-500/[0.06] hover:bg-emerald-500/10' : styles.bg,
className,
)}
>
{/* Icon */}
<div className="text-lg leading-none">{icon}</div>
{/* Title — 2 lines max */}
<span className={cn(
'text-[11px] font-medium leading-tight line-clamp-2 min-h-[2lh]',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
{/* Progress ring / check */}
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
);
}
// ── Expanded card (spans full row) ──
return (
<div
className={cn(
'col-span-full rounded-xl ring-1 transition-all overflow-hidden',
completed ? 'bg-emerald-500/[0.06] ring-emerald-500/20' : cn(styles.expandedBg, styles.border),
className,
)}
>
{/* Compact header — click to collapse */}
<button
type="button"
onClick={() => onToggle(id)}
className="w-full flex items-center gap-3 p-3 text-left cursor-pointer select-none"
>
<div className="text-lg leading-none shrink-0">{icon}</div>
<span className={cn(
'text-sm font-medium flex-1 min-w-0',
completed && 'text-emerald-600 dark:text-emerald-400',
)}>
{title}
</span>
<ProgressRing progress={progress} completed={completed} category={category} />
</button>
{/* Expanded details */}
<div className="px-3 pb-3 pt-0 space-y-2">
{children}
</div>
</div>
);
}
// ─── Shared detail sub-components ─────────────────────────────────────────────
/** Description text */
export function MissionDescription({ children }: { children: ReactNode }) {
return <p className="text-xs text-muted-foreground leading-snug">{children}</p>;
}
/** Progress bar with fraction label */
export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) {
const pct = required > 0 ? Math.round((current / required) * 100) : 0;
return (
<div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
<span className="tabular-nums">{current} / {required}</span>
<span className="tabular-nums">{pct}%</span>
</div>
<Progress value={pct} className={cn('h-1.5', completed && '[&>div]:bg-emerald-500')} />
</div>
);
}
/** Inline action link (navigate, external, modal) */
export function MissionAction({
label,
type,
onClick,
}: {
label: string;
type: 'navigate' | 'external_link' | 'open_modal';
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{label}
{type === 'external_link' ? (
<ExternalLink className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
);
}
/** Dynamic / live task hint */
export function DynamicHint({ current, required }: { current: number; required: number }) {
return (
<div className="flex items-center gap-1.5 text-[11px] text-amber-600/80 dark:text-amber-400/80">
<AlertCircle className="size-3 shrink-0" />
<span>Lowest stat: {current}% (need {required}%+)</span>
</div>
);
}
@@ -0,0 +1,221 @@
// src/blobbi/actions/components/HatchTasksPanel.tsx
/**
* UI component for displaying hatch task progress.
* Shows a list of tasks with progress indicators and action buttons.
*/
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';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { HatchTask } from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HatchTasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching?: boolean;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') {
onOpenPostModal();
}
break;
}
};
const progress = task.required > 1
? Math.round((task.current / task.required) * 100)
: task.completed ? 100 : 0;
return (
<div
className={cn(
"flex items-center gap-4 p-4 rounded-xl border transition-all",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Status indicator */}
<div className={cn(
"size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-5" />
) : task.required > 1 ? (
<span className="text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className={cn(
"font-medium",
task.completed && "text-emerald-600 dark:text-emerald-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs">
Complete
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{task.description}
</p>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !task.completed && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
</div>
{/* Action button */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2"
>
{task.actionLabel}
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</Button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HatchTasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onHatch,
isHatching = false,
}: HatchTasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = Math.round((completedCount / totalTasks) * 100);
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🥚</span>
Hatch Tasks
</CardTitle>
<CardDescription>
Complete these tasks to hatch your Blobbi
</CardDescription>
</div>
<Badge variant="outline" className="text-base px-3 py-1">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-muted-foreground">Overall progress</span>
<span className="font-medium">{overallProgress}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{tasks.map(task => (
<TaskRow
key={task.id}
task={task}
onOpenPostModal={onOpenPostModal}
/>
))}
{/* Hatch button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<Button
onClick={onHatch}
disabled={isHatching}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
{isHatching ? (
<>
<Loader2 className="size-5 animate-spin" />
Hatching...
</>
) : (
<>
<span className="text-xl">🐣</span>
Hatch Your Blobbi!
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,251 @@
// src/blobbi/actions/components/InlineMusicPlayer.tsx
import { useCallback, useEffect } from 'react';
import { Music, Play, Pause, RotateCcw, MoreHorizontal, Loader2, AlertCircle, X, Volume2, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import type { SelectedTrack } from './PlayMusicModal';
// Re-export for external use
export type { SelectedTrack } from './PlayMusicModal';
interface InlineMusicPlayerProps {
/** The selected track */
selection: SelectedTrack;
/** Called when user wants to change the track */
onChangeTrack: () => void;
/** Called when user closes the player */
onClose: () => void;
/** Called when playback starts (for Blobbi reaction state) */
onPlaybackStart?: () => void;
/** Called when playback stops/pauses (for Blobbi reaction state) */
onPlaybackStop?: () => void;
/** Whether the action has been published (playback only starts after publish) */
isPublished: boolean;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineMusicPlayer({
selection,
onChangeTrack,
onClose,
onPlaybackStart,
onPlaybackStop,
isPublished,
isPublishing,
}: InlineMusicPlayerProps) {
const {
state: playbackState,
error: playbackError,
load,
toggle,
restart,
stop,
isPlaying,
volume,
setVolume,
cleanup,
} = useAudioPlayback({
onEnded: () => {
onPlaybackStop?.();
},
});
// Auto-start playback when first published (idle -> playing)
// Note: 'stopped' state is NOT included here - stop is a terminal state
// that requires explicit user action (play button) to restart
useEffect(() => {
if (isPublished && playbackState === 'idle') {
load(selection.url, true);
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(selection.url, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only react to selection.url changes
}, [selection.url]);
// Notify on playback state changes
useEffect(() => {
if (isPlaying) {
onPlaybackStart?.();
} else if (playbackState === 'paused' || playbackState === 'stopped') {
onPlaybackStop?.();
}
}, [isPlaying, playbackState, onPlaybackStart, onPlaybackStop]);
// Cleanup on close
const handleClose = useCallback(() => {
stop();
cleanup();
onPlaybackStop?.();
onClose();
}, [stop, cleanup, onPlaybackStop, onClose]);
// Handle play/pause toggle
const handleToggle = useCallback(async () => {
if (playbackState === 'idle' || playbackState === 'stopped') {
load(selection.url, true);
} else {
await toggle();
}
}, [playbackState, selection.url, load, toggle]);
// Track info
const trackTitle = selection.track.title;
const trackArtist = selection.track.artist;
const isLoading = playbackState === 'loading' || isPublishing;
const hasError = playbackState === 'error';
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isPlaying && "ring-2 ring-pink-500/30"
)}>
{/* Main content row */}
<div className="flex items-center gap-3 p-3">
{/* Music icon / Now Playing indicator */}
<div className={cn(
"size-10 rounded-lg flex items-center justify-center shrink-0",
isPlaying
? "bg-pink-500/20"
: "bg-muted"
)}>
<Music className={cn(
"size-5",
isPlaying ? "text-pink-500 animate-pulse" : "text-muted-foreground"
)} />
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{trackTitle}</p>
{trackArtist && (
<p className="text-xs text-muted-foreground truncate">{trackArtist}</p>
)}
{!trackArtist && (
<p className="text-xs text-muted-foreground">
{isPlaying ? 'Now playing...' : isPublishing ? 'Starting...' : 'Ready to play'}
</p>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
{/* Play/Pause button */}
<Button
size="icon"
variant="ghost"
onClick={handleToggle}
disabled={isLoading || !isPublished}
className="size-9 rounded-full"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
{/* Restart button - only show when actively playing or paused */}
{isPublished && (playbackState === 'playing' || playbackState === 'paused') && (
<Button
size="icon"
variant="ghost"
onClick={() => {
restart();
}}
className="size-9 rounded-full"
title="Restart from beginning"
>
<RotateCcw className="size-3.5" />
</Button>
)}
{/* Volume control */}
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-9 rounded-full"
title={volume === 0 ? 'Unmute' : 'Volume'}
>
{volume === 0 ? (
<VolumeX className="size-4" />
) : (
<Volume2 className="size-4" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
align="center"
className="w-32 p-3"
>
<Slider
value={[volume * 100]}
onValueChange={([val]) => setVolume(val / 100)}
max={100}
step={1}
className="w-full"
/>
</PopoverContent>
</Popover>
{/* Change track button */}
<Button
size="icon"
variant="ghost"
onClick={onChangeTrack}
disabled={isPublishing}
className="size-9 rounded-full"
>
<MoreHorizontal className="size-4" />
</Button>
{/* Close button */}
<Button
size="icon"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="size-9 rounded-full text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
</div>
</div>
{/* Error message */}
{hasError && playbackError && (
<div className="px-3 pb-3">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{playbackError.message}</p>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,487 @@
// src/blobbi/actions/components/InlineSingCard.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import {
Mic,
Play,
Pause,
Square,
FileText,
Check,
X,
Loader2,
AlertCircle,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useAudioPlayback } from '../hooks/useAudioPlayback';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'error';
interface InlineSingCardProps {
/** Called when user confirms the singing action (publish the action) */
onConfirm: () => Promise<void>;
/** Called when user closes the sing card */
onClose: () => void;
/** Called when recording starts (for Blobbi reaction) */
onRecordingStart?: () => void;
/** Called when recording stops (for Blobbi reaction) */
onRecordingStop?: () => void;
/** Whether publishing is in progress */
isPublishing: boolean;
}
// ─── MIME Type Selection ──────────────────────────────────────────────────────
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function InlineSingCard({
onConfirm,
onClose,
onRecordingStart,
onRecordingStop,
isPublishing,
}: InlineSingCardProps) {
// Recording state
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [recordingError, setRecordingError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
// Lyrics state
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
// Refs
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Audio playback for preview
const {
state: playbackState,
error: playbackError,
load: loadAudio,
toggle: togglePlayback,
stop: stopPlayback,
isPlaying,
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
try {
mediaRecorderRef.current.stop();
} catch {
// Ignore errors during cleanup
}
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Cleanup playback
cleanupPlayback();
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl, cleanupPlayback]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, [cleanupAll]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
setRecordingState('idle');
setRecordingError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
actualMimeTypeRef.current = undefined;
// Keep lyrics
}, [cleanupAll]);
// Check browser support
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setRecordingError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setRecordingError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get supported MIME type
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
mediaRecorder = new MediaRecorder(stream);
}
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setRecordingError('Recording failed. Please try again.');
setRecordingState('error');
};
mediaRecorder.start(100);
setRecordingState('recording');
setRecordingDuration(0);
// Notify parent that recording started (for Blobbi reaction)
onRecordingStart?.();
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setRecordingError('Microphone access was denied.');
} else if (err.name === 'NotFoundError') {
setRecordingError('No microphone found.');
} else {
setRecordingError(err.message);
}
} else {
setRecordingError('Failed to access microphone.');
}
setRecordingState('error');
}
}, [onRecordingStart]);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
// Notify parent that recording stopped (for Blobbi reaction)
onRecordingStop?.();
}, [onRecordingStop]);
// Handle preview playback
const handlePreview = useCallback(() => {
if (!audioUrl) return;
if (playbackState === 'idle') {
loadAudio(audioUrl, true);
} else {
togglePlayback();
}
}, [audioUrl, playbackState, loadAudio, togglePlayback]);
// Handle confirm
const handleConfirm = useCallback(async () => {
stopPlayback();
await onConfirm();
// After successful publish, close the card
onClose();
}, [stopPlayback, onConfirm, onClose]);
// Handle close
const handleClose = useCallback(() => {
cleanupAll();
onClose();
}, [cleanupAll, onClose]);
// Handle lyrics toggle
const handleLyricsToggle = useCallback(() => {
if (!currentLyrics && !showLyrics) {
// Generate lyrics on first open
setCurrentLyrics(getRandomLyrics());
}
setShowLyrics(!showLyrics);
}, [currentLyrics, showLyrics]);
// Get new lyrics
const handleNewLyrics = useCallback(() => {
setCurrentLyrics(getRandomLyrics());
}, []);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded';
const isRecording = recordingState === 'recording';
const canConfirm = hasRecording && !isPublishing;
return (
<div className="mx-4 sm:mx-6 mb-4">
<div className={cn(
"rounded-xl border bg-card/80 backdrop-blur-sm overflow-hidden",
"shadow-sm transition-all",
isRecording && "ring-2 ring-red-500/30"
)}>
{/* Lyrics panel (expands upward visually by being above controls) */}
{showLyrics && currentLyrics && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{currentLyrics.title}</span>
<Button
size="icon"
variant="ghost"
onClick={handleNewLyrics}
className="size-7 rounded-full"
>
<RefreshCw className="size-3" />
</Button>
</div>
<div className="p-3 rounded-lg bg-muted/50 text-sm leading-relaxed whitespace-pre-line max-h-32 overflow-y-auto">
{currentLyrics.lines.join('\n')}
</div>
</div>
)}
{/* Status row (recording/recorded info) */}
{(isRecording || hasRecording) && (
<div className="px-3 pt-3 pb-2 border-b border-border/50">
<div className="flex items-center justify-center gap-2">
{isRecording && (
<>
<div className="size-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-sm font-mono font-medium text-red-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recording...</span>
</>
)}
{hasRecording && !isRecording && (
<>
<Check className="size-4 text-purple-500" />
<span className="text-sm font-mono font-medium text-purple-500">
{formatDuration(recordingDuration)}
</span>
<span className="text-xs text-muted-foreground">Recorded</span>
</>
)}
</div>
</div>
)}
{/* Error message */}
{(recordingError || playbackError) && (
<div className="px-3 pt-2">
<div className="flex items-start gap-2 p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p className="text-xs">{recordingError || playbackError?.message}</p>
</div>
</div>
)}
{/* Main controls row */}
<div className="flex items-center justify-between gap-2 p-3">
{/* Left: Lyrics button */}
<Button
size="icon"
variant={showLyrics ? "secondary" : "ghost"}
onClick={handleLyricsToggle}
className="size-10 rounded-full shrink-0"
>
<FileText className="size-4" />
</Button>
{/* Center: Record/Stop button */}
<div className="flex items-center gap-2">
{!isRecording && !hasRecording && (
<Button
onClick={startRecording}
disabled={isPublishing}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-4 mr-2" />
Sing
</Button>
)}
{isRecording && (
<Button
onClick={stopRecording}
variant="destructive"
className="rounded-full px-6"
>
<Square className="size-4 mr-2" />
Stop
</Button>
)}
{hasRecording && !isRecording && (
<>
<Button
onClick={resetRecording}
variant="outline"
size="icon"
className="size-10 rounded-full"
>
<RefreshCw className="size-4" />
</Button>
<Button
onClick={handleConfirm}
disabled={!canConfirm}
className="rounded-full px-6 bg-purple-500 hover:bg-purple-600"
>
{isPublishing ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Check className="size-4 mr-2" />
)}
{isPublishing ? 'Singing...' : 'Sing for Blobbi'}
</Button>
</>
)}
</div>
{/* Right: Preview button (when recording exists) */}
{hasRecording ? (
<Button
size="icon"
variant="ghost"
onClick={handlePreview}
disabled={isPublishing}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
) : (
/* Close button when no recording */
<Button
size="icon"
variant="ghost"
onClick={handleClose}
className="size-10 rounded-full shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</Button>
)}
</div>
{/* Close button row when recording exists */}
{hasRecording && (
<div className="px-3 pb-3 pt-0 flex justify-end">
<Button
size="sm"
variant="ghost"
onClick={handleClose}
disabled={isPublishing}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3 mr-1" />
Cancel
</Button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,301 @@
// src/blobbi/actions/components/PlayMusicModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Music, Play, Pause, Check, Loader2, Volume2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
getAllTracks,
formatTrackDuration,
type BlobbiTrack,
} from '../lib/blobbi-track-catalog';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Selected track for the music player
*/
export interface SelectedTrack {
track: BlobbiTrack;
url: string;
}
interface PlayMusicModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called with the selected track when user confirms */
onConfirm: (selection: SelectedTrack) => void;
isLoading: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PlayMusicModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: PlayMusicModalProps) {
const [selectedTrack, setSelectedTrack] = useState<SelectedTrack | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
// Track the current audio source URL to detect changes
const currentAudioUrlRef = useRef<string | null>(null);
const tracks = getAllTracks();
// Cleanup audio on unmount
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
setSelectedTrack(null);
setIsPlaying(false);
setError(null);
currentAudioUrlRef.current = null;
}
}, [open]);
// Handle selecting a track
const handleSelectTrack = useCallback((track: BlobbiTrack) => {
// Stop current playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
setSelectedTrack({ track, url: track.url });
setError(null);
}, []);
// Handle play/pause preview
const handleTogglePlay = useCallback(() => {
if (!selectedTrack) return;
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;
if (needsNewAudio) {
// Stop and cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the correct source
audioRef.current = new Audio(audioUrl);
currentAudioUrlRef.current = audioUrl;
audioRef.current.onended = () => setIsPlaying(false);
audioRef.current.onerror = () => {
setError('Failed to load this track. Please try another one.');
setIsPlaying(false);
};
}
if (isPlaying && !needsNewAudio) {
// Pause current playback
audioRef.current?.pause();
setIsPlaying(false);
} else {
// Start playback (either new source or resuming)
audioRef.current?.play().catch(() => {
setError('Failed to play this track. Please try another one.');
setIsPlaying(false);
});
setIsPlaying(true);
}
}, [selectedTrack, isPlaying]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (!selectedTrack) return;
// Stop playback
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onConfirm(selectedTrack);
}, [selectedTrack, onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen && audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
onOpenChange(isOpen);
}, [onOpenChange]);
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-pink-500/20 to-pink-500/5 flex items-center justify-center">
<Music className="size-5 text-pink-500" />
</div>
<div>
<DialogTitle className="text-xl">Play Music</DialogTitle>
<p className="text-sm text-muted-foreground">
Choose a track to play for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content - Track List */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<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>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
{/* Preview Controls */}
{selectedTrack && (
<div className="mb-4 p-3 rounded-lg bg-card border">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="outline"
onClick={handleTogglePlay}
className="size-10 rounded-full shrink-0"
>
{isPlaying ? (
<Pause className="size-4" />
) : (
<Play className="size-4 ml-0.5" />
)}
</Button>
<div className="flex-1 min-w-0">
<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>
</div>
{isPlaying && (
<Volume2 className="size-4 text-primary animate-pulse shrink-0" />
)}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedTrack || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Playing...
</>
) : (
<>
<Music className="size-4 mr-2" />
Play for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Track Row Component ──────────────────────────────────────────────────────
interface TrackRowProps {
track: BlobbiTrack;
isSelected: boolean;
onSelect: () => void;
}
function TrackRow({ track, isSelected, onSelect }: TrackRowProps) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"w-full p-3 rounded-xl text-left transition-all",
"border hover:border-primary/30",
isSelected
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border bg-card/60"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"size-10 rounded-lg flex items-center justify-center",
isSelected ? "bg-primary/20" : "bg-muted"
)}>
<Music className={cn(
"size-5",
isSelected ? "text-primary" : "text-muted-foreground"
)} />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground">{track.artist}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm text-muted-foreground">
{formatTrackDuration(track.durationSeconds)}
</span>
{isSelected && <Check className="size-4 text-primary" />}
</div>
</div>
</button>
);
}
+601
View File
@@ -0,0 +1,601 @@
// src/blobbi/actions/components/SingModal.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
import { Mic, MicOff, Play, Pause, Square, Loader2, AlertCircle, RotateCcw, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getRandomLyrics, type LyricsEntry } from '../lib/blobbi-random-lyrics';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SingModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isLoading: boolean;
}
type RecordingState = 'idle' | 'requesting' | 'recording' | 'recorded' | 'playing' | 'error';
// ─── MIME Type Selection Helper ───────────────────────────────────────────────
/**
* Ordered list of MIME types to try for audio recording.
* The first supported type will be used.
*/
const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
] as const;
/**
* Get the first supported MIME type for MediaRecorder.
* Returns undefined if no explicit MIME type is supported (let browser decide).
*/
function getSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') {
return undefined;
}
for (const mimeType of AUDIO_MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
// No explicit MIME type supported, let browser use default
return undefined;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SingModal({
open,
onOpenChange,
onConfirm,
isLoading,
}: SingModalProps) {
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
const [error, setError] = useState<string | null>(null);
const [playbackError, setPlaybackError] = useState<string | null>(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [currentLyrics, setCurrentLyrics] = useState<LyricsEntry | null>(null);
const [showLyrics, setShowLyrics] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// Stop media recorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Stop audio playback
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
// Revoke URL
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
}, [audioUrl]);
const resetRecording = useCallback(() => {
cleanup();
setRecordingState('idle');
setError(null);
setPlaybackError(null);
setRecordingDuration(0);
setAudioUrl(null);
chunksRef.current = [];
currentPlaybackUrlRef.current = null;
actualMimeTypeRef.current = undefined;
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open, cleanup, resetRecording]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
setCurrentLyrics(lyrics);
setShowLyrics(true);
}, []);
// Check if browser supports media recording
const checkRecordingSupport = (): boolean => {
if (typeof navigator === 'undefined') return false;
if (!navigator.mediaDevices) return false;
if (!navigator.mediaDevices.getUserMedia) return false;
if (typeof MediaRecorder === 'undefined') return false;
return true;
};
// Start recording
const startRecording = useCallback(async () => {
if (!checkRecordingSupport()) {
setError('Audio recording is not supported in this browser.');
setRecordingState('error');
return;
}
setRecordingState('requesting');
setError(null);
setPlaybackError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
streamRef.current = stream;
chunksRef.current = [];
// Get the first supported MIME type using our helper
const supportedMimeType = getSupportedAudioMimeType();
// Create MediaRecorder with or without explicit MIME type
let mediaRecorder: MediaRecorder;
if (supportedMimeType) {
mediaRecorder = new MediaRecorder(stream, { mimeType: supportedMimeType });
} else {
// Let browser choose default MIME type
mediaRecorder = new MediaRecorder(stream);
}
// Store the actual MIME type being used (may differ from what we requested)
actualMimeTypeRef.current = mediaRecorder.mimeType || supportedMimeType;
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks using the actual MIME type used by the recorder
const blobMimeType = actualMimeTypeRef.current || 'audio/webm';
const blob = new Blob(chunksRef.current, { type: blobMimeType });
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setRecordingState('recorded');
// Stop stream tracks
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
mediaRecorder.onerror = () => {
setError('Recording failed. Please try again.');
setRecordingState('error');
};
// Start recording
mediaRecorder.start(100); // Collect data every 100ms
setRecordingState('recording');
setRecordingDuration(0);
// Start timer
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setError('Microphone access was denied. Please allow microphone access and try again.');
} else if (err.name === 'NotFoundError') {
setError('No microphone found. Please connect a microphone and try again.');
} else {
setError(`Failed to access microphone: ${err.message}`);
}
} else {
setError('Failed to access microphone. Please try again.');
}
setRecordingState('error');
}
}, []);
// Stop recording
const stopRecording = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
}, []);
// Track the current audio URL to detect changes
const currentPlaybackUrlRef = useRef<string | null>(null);
// Play/pause preview
const togglePlayback = useCallback(() => {
if (!audioUrl) return;
// Clear previous playback error when attempting to play
setPlaybackError(null);
if (recordingState === 'playing') {
if (audioRef.current) {
audioRef.current.pause();
}
setRecordingState('recorded');
} else {
// Check if we need to create a new Audio instance (URL changed or first time)
const needsNewAudio = !audioRef.current || currentPlaybackUrlRef.current !== audioUrl;
if (needsNewAudio) {
// Cleanup old audio if exists
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
}
// Create new Audio instance with the recorded audio URL
audioRef.current = new Audio(audioUrl);
currentPlaybackUrlRef.current = audioUrl;
audioRef.current.onended = () => setRecordingState('recorded');
// Handle playback errors with user-visible message
audioRef.current.onerror = () => {
setPlaybackError('This browser could not play the recorded audio preview. Your recording was still created successfully.');
setRecordingState('recorded');
};
}
audioRef.current?.play()
.then(() => {
setRecordingState('playing');
})
.catch((err) => {
console.error('Failed to play recording:', err);
// Provide user-friendly error message
if (err.name === 'NotSupportedError') {
setPlaybackError('Recording was created, but playback preview is not supported in this browser.');
} else if (err.name === 'NotAllowedError') {
setPlaybackError('Playback was blocked. Try interacting with the page first.');
} else {
setPlaybackError('Could not play the recording preview. Your recording was still created successfully.');
}
setRecordingState('recorded');
});
}
}, [audioUrl, recordingState]);
// Handle confirm
const handleConfirm = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
}
onConfirm();
}, [onConfirm]);
// Handle close
const handleClose = useCallback((isOpen: boolean) => {
if (!isOpen) {
cleanup();
}
onOpenChange(isOpen);
}, [onOpenChange, cleanup]);
// Format duration
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const hasRecording = recordingState === 'recorded' || recordingState === 'playing';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-500/5 flex items-center justify-center">
<Mic className="size-5 text-purple-500" />
</div>
<div>
<DialogTitle className="text-xl">Sing</DialogTitle>
<p className="text-sm text-muted-foreground">
Record yourself singing for your Blobbi
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="flex-1 px-6 py-8">
<div className="flex flex-col items-center justify-center gap-6">
{/* Recording Visualization */}
<div className={cn(
"relative size-40 rounded-full flex items-center justify-center transition-all",
recordingState === 'recording' && "animate-pulse",
recordingState === 'recording'
? "bg-red-500/10 ring-4 ring-red-500/30"
: hasRecording
? "bg-purple-500/10 ring-4 ring-purple-500/30"
: "bg-muted"
)}>
{/* Animated rings for recording */}
{recordingState === 'recording' && (
<>
<div className="absolute inset-0 rounded-full bg-red-500/10 animate-ping" />
<div className="absolute inset-4 rounded-full bg-red-500/10 animate-ping animation-delay-150" />
</>
)}
{/* Icon */}
<div className={cn(
"relative size-20 rounded-full flex items-center justify-center",
recordingState === 'recording'
? "bg-red-500 text-white"
: hasRecording
? "bg-purple-500 text-white"
: "bg-muted-foreground/20"
)}>
{recordingState === 'requesting' ? (
<Loader2 className="size-8 animate-spin" />
) : recordingState === 'recording' ? (
<Mic className="size-8" />
) : hasRecording ? (
recordingState === 'playing' ? (
<Pause className="size-8" />
) : (
<Play className="size-8 ml-1" />
)
) : (
<MicOff className="size-8 text-muted-foreground" />
)}
</div>
</div>
{/* Duration / Status */}
<div className="text-center">
{recordingState === 'idle' && (
<p className="text-muted-foreground">Tap the button below to start recording</p>
)}
{recordingState === 'requesting' && (
<p className="text-muted-foreground">Requesting microphone access...</p>
)}
{recordingState === 'recording' && (
<>
<p className="text-3xl font-mono font-bold text-red-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">Recording...</p>
</>
)}
{hasRecording && (
<>
<p className="text-3xl font-mono font-bold text-purple-500">
{formatDuration(recordingDuration)}
</p>
<p className="text-sm text-muted-foreground mt-1">
{recordingState === 'playing' ? 'Playing...' : 'Tap to preview'}
</p>
</>
)}
{recordingState === 'error' && (
<p className="text-destructive">Recording failed</p>
)}
</div>
{/* Error Message */}
{error && (
<div className="w-full p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-destructive mt-0.5 shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
)}
{/* Playback Error Message (non-fatal, recording still works) */}
{playbackError && (
<div className="w-full 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">{playbackError}</p>
</div>
</div>
)}
{/* Lyrics Helper */}
<div className="w-full">
{!currentLyrics ? (
<Button
variant="outline"
size="sm"
onClick={handleRandomLyrics}
className="w-full gap-2"
>
<Sparkles className="size-4" />
Need lyrics? Get random lyrics
</Button>
) : (
<div className="rounded-lg border bg-card/60">
<button
type="button"
onClick={() => setShowLyrics(!showLyrics)}
className="w-full flex items-center justify-between p-3 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-purple-500" />
<span className="font-medium text-sm">{currentLyrics.title}</span>
</div>
{showLyrics ? (
<ChevronUp className="size-4 text-muted-foreground" />
) : (
<ChevronDown className="size-4 text-muted-foreground" />
)}
</button>
{showLyrics && (
<div className="px-3 pb-3 pt-0">
<div className="p-3 rounded-md bg-muted/50 text-sm leading-relaxed whitespace-pre-line">
{currentLyrics.lines.join('\n')}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRandomLyrics}
className="w-full mt-2 gap-2 text-muted-foreground"
>
<RotateCcw className="size-3" />
Get different lyrics
</Button>
</div>
)}
</div>
)}
</div>
{/* Recording Controls */}
<div className="flex items-center gap-3">
{recordingState === 'idle' || recordingState === 'error' ? (
<Button
size="lg"
onClick={startRecording}
className="rounded-full px-8 bg-purple-500 hover:bg-purple-600"
>
<Mic className="size-5 mr-2" />
Start Recording
</Button>
) : recordingState === 'recording' ? (
<Button
size="lg"
variant="destructive"
onClick={stopRecording}
className="rounded-full px-8"
>
<Square className="size-5 mr-2" />
Stop
</Button>
) : hasRecording ? (
<>
<Button
size="lg"
variant="outline"
onClick={togglePlayback}
className="rounded-full"
>
{recordingState === 'playing' ? (
<>
<Pause className="size-5 mr-2" />
Pause
</>
) : (
<>
<Play className="size-5 mr-2" />
Preview
</>
)}
</Button>
<Button
size="lg"
variant="ghost"
onClick={resetRecording}
className="rounded-full"
>
<RotateCcw className="size-5 mr-2" />
Re-record
</Button>
</>
) : null}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-muted/30">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => handleClose(false)}
className="flex-1"
disabled={isLoading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!hasRecording || isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Singing...
</>
) : (
<>
<Mic className="size-4 mr-2" />
Sing for Blobbi
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,125 @@
// src/blobbi/actions/components/StartEvolutionDialog.tsx
/**
* Dialog for confirming start of evolution.
*
* Evolution is simpler than incubation:
* - Only baby Blobbis can evolve
* - Shows restart confirmation if already evolving
* - Otherwise shows normal start confirmation
*/
import { Loader2, AlertTriangle, Sparkles } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartEvolutionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start evolving */
companion: BlobbiCompanion | null;
/** Called when confirmed */
onConfirm: () => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartEvolutionDialog({
open,
onOpenChange,
companion,
onConfirm,
isPending,
}: StartEvolutionDialogProps) {
// Check if the current Blobbi is already evolving
const isAlreadyEvolving = companion?.state === 'evolving';
// Determine title and description based on state
const getDialogContent = () => {
if (isAlreadyEvolving) {
return {
title: 'Restart Evolution?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
<strong>{companion?.name}</strong> is already evolving. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Evolution',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Evolution',
icon: <Sparkles className="size-5 text-primary" />,
description: (
<>
Starting evolution begins <strong>{companion?.name}</strong>'s transformation journey.
Complete all the tasks to evolve your baby Blobbi into an adult!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Evolution',
buttonClass: 'bg-gradient-to-r from-violet-500 to-purple-500 hover:from-violet-600 hover:to-purple-600 text-white',
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,180 @@
// src/blobbi/actions/components/StartIncubationDialog.tsx
/**
* Dialog for confirming start of incubation.
*
* Determines the mode and passes it explicitly to the confirm callback:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating)
* - 'switch': Stop another Blobbi first, then start this one
*
* The mode is determined by UI state, NOT auto-detected by the hook.
* This makes the flow explicit and predictable.
*/
import { useMemo } from 'react';
import { Loader2, AlertTriangle, ArrowRightLeft } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { StartIncubationMode } from '../hooks/useBlobbiIncubation';
// ─── Types ────────────────────────────────────────────────────────────────────
interface StartIncubationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The companion to start incubating */
companion: BlobbiCompanion | null;
/** All companions in the collection (to check for other incubating Blobbis) */
companions?: BlobbiCompanion[];
/** Called with explicit mode and optional stopOtherD when confirmed */
onConfirm: (mode: StartIncubationMode, stopOtherD?: string) => void;
isPending: boolean;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function StartIncubationDialog({
open,
onOpenChange,
companion,
companions = [],
onConfirm,
isPending,
}: StartIncubationDialogProps) {
// Check if the current Blobbi is already in a task state
const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving';
// Check if another Blobbi (not this one) is currently incubating
const otherIncubatingBlobbi = useMemo(() => {
if (!companion) return null;
return companions.find(c =>
c.d !== companion.d &&
c.state === 'incubating' &&
c.stage === 'egg'
) ?? null;
}, [companion, companions]);
// Determine the mode based on current state
const mode: StartIncubationMode = useMemo(() => {
if (isAlreadyInTaskState) return 'restart';
if (otherIncubatingBlobbi) return 'switch';
return 'start';
}, [isAlreadyInTaskState, otherIncubatingBlobbi]);
// Handle confirm with explicit mode
const handleConfirm = () => {
if (mode === 'switch' && otherIncubatingBlobbi) {
onConfirm(mode, otherIncubatingBlobbi.d);
} else {
onConfirm(mode);
}
};
// Determine title and description based on mode
const getDialogContent = () => {
if (mode === 'restart') {
return {
title: 'Restart Incubation?',
icon: <AlertTriangle className="size-5 text-amber-500" />,
description: (
<>
Your Blobbi is already {companion?.state}. Starting over will{' '}
<strong>reset all task progress</strong> and begin from the beginning.
<br /><br />
Are you sure you want to restart?
</>
),
buttonText: 'Restart Incubation',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
if (mode === 'switch') {
return {
title: 'Switch Incubation?',
icon: <ArrowRightLeft className="size-5 text-amber-500" />,
description: (
<>
<strong>{otherIncubatingBlobbi?.name}</strong> is currently incubating.
Only one Blobbi can incubate at a time.
<br /><br />
Starting incubation for <strong>{companion?.name}</strong> will{' '}
<strong>stop {otherIncubatingBlobbi?.name}'s incubation</strong> and{' '}
reset their task progress.
<br /><br />
Do you want to switch?
</>
),
buttonText: 'Switch & Start',
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
};
}
return {
title: 'Start Incubation',
icon: null,
description: (
<>
Starting incubation begins your Blobbi's hatching journey.
Complete all the tasks to hatch your egg into a baby Blobbi!
<br /><br />
Ready to begin?
</>
),
buttonText: 'Start Incubation',
buttonClass: undefined,
};
};
const content = getDialogContent();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{content.icon}
{content.title}
</AlertDialogTitle>
<AlertDialogDescription>
{content.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
disabled={isPending}
className={content.buttonClass}
>
{isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Starting...
</>
) : (
content.buttonText
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,195 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* Card-grid presentation for hatch / evolve tasks.
*
* Each task is a compact card in a 2-column grid.
* Tapping a card expands it inline (full row) to reveal details.
* Only one card is expanded at a time.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Palette,
Droplets,
MessageSquare,
Heart,
UserPen,
Activity,
Loader2,
HelpCircle,
} from 'lucide-react';
import { openUrl } from '@/lib/downloadFile';
import { Button } from '@/components/ui/button';
import type { HatchTask } from '../hooks/useHatchTasks';
import type { MissionCategory } from './ExpandableMissionCard';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
MissionAction,
DynamicHint,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
onOpenPostModal: () => void;
onComplete: () => void;
isCompleting?: boolean;
completeLabel: string;
completingLabel: string;
completeEmoji: string;
/** Mission category for styling the cards */
category?: MissionCategory;
}
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
/** Map task ids to lucide icons. Falls back to a generic icon. */
function TaskIcon({ taskId }: { taskId: string }) {
const iconClass = 'size-5';
switch (taskId) {
case 'create_themes':
return <Palette className={iconClass} />;
case 'color_moments':
return <Droplets className={iconClass} />;
case 'create_posts':
return <MessageSquare className={iconClass} />;
case 'interactions':
return <Heart className={iconClass} />;
case 'edit_profile':
return <UserPen className={iconClass} />;
case 'maintain_stats':
return <Activity className={iconClass} />;
default:
return <HelpCircle className={iconClass} />;
}
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function TasksPanel({
tasks,
allCompleted,
isLoading,
onOpenPostModal,
onComplete,
isCompleting = false,
completeLabel,
completingLabel,
completeEmoji,
category = 'hatch',
}: TasksPanelProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const navigate = useNavigate();
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
{/* Card grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{tasks.map((task) => {
const isDynamic = task.type === 'dynamic';
const progress =
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
const handleAction = () => {
if (!task.action || !task.actionTarget) return;
switch (task.action) {
case 'navigate':
navigate(task.actionTarget);
break;
case 'external_link':
openUrl(task.actionTarget);
break;
case 'open_modal':
if (task.actionTarget === 'blobbi_post') onOpenPostModal();
break;
}
};
return (
<ExpandableMissionCard
key={task.id}
id={task.id}
category={category}
icon={<TaskIcon taskId={task.id} />}
title={task.name}
completed={task.completed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === task.id}
onToggle={handleToggle}
>
{/* Expanded content */}
<MissionDescription>{task.description}</MissionDescription>
{/* Progress bar for multi-step tasks */}
{task.required > 1 && !isDynamic && (
<MissionProgress
current={task.current}
required={task.required}
completed={task.completed}
/>
)}
{/* Dynamic stat hint */}
{isDynamic && !task.completed && (
<DynamicHint current={task.current} required={task.required} />
)}
{/* Action link */}
{task.action && task.actionLabel && !task.completed && (
<MissionAction
label={task.actionLabel}
type={task.action}
onClick={handleAction}
/>
)}
</ExpandableMissionCard>
);
})}
</div>
{/* CTA button when all tasks are done */}
{allCompleted && (
<Button
onClick={onComplete}
disabled={isCompleting}
size="lg"
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white shadow-sm"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-lg">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
)}
</div>
);
}
@@ -0,0 +1,232 @@
// src/blobbi/actions/hooks/useActiveTaskProcess.ts
/**
* Central abstraction for the active task process (hatch or evolve).
*
* This hook consolidates all scattered if/else logic for determining:
* - Which process is active (incubating vs evolving)
* - Which tasks to use (hatch vs evolve)
* - Thresholds and configuration
* - Badge-related computed values
*
* ARCHITECTURE RULES:
* - Computed tasks remain the source of truth
* - Tags are cache only for PERSISTENT tasks
* - Dynamic tasks are NEVER persisted
* - Badge counts ALL incomplete tasks (persistent + dynamic)
*/
import { useMemo } from 'react';
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';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
/** The type of task process currently active */
export type TaskProcessType = 'hatch' | 'evolve' | null;
/**
* Configuration for the active task process.
* This provides a unified interface regardless of whether
* the process is hatch or evolve.
*/
export interface TaskProcessConfig {
/** The type of process ('hatch' | 'evolve' | null) */
type: TaskProcessType;
/** Whether there is an active task process */
isActive: boolean;
/** Required interactions threshold for the current process */
interactionThreshold: number;
}
/**
* Result of the active task process hook.
* Provides unified access to all task-related state.
*/
export interface ActiveTaskProcessResult {
/** Configuration for the current process */
config: TaskProcessConfig;
/** All tasks for the current process (empty if no active process) */
tasks: HatchTask[];
/** Whether tasks are still loading */
isLoading: boolean;
/** Whether all tasks (persistent + dynamic) are complete */
allCompleted: boolean;
/** Whether all persistent tasks are complete */
persistentTasksComplete: boolean;
/** Whether the dynamic task is complete */
dynamicTaskComplete: boolean;
/** Refetch function for current tasks */
refetch: () => void;
// ─── Badge-related computed values ───
/**
* Count of ALL remaining incomplete tasks (persistent + dynamic).
* This is used for the badge display.
* Dynamic tasks ARE counted here but are NEVER synced to tags.
*/
remainingTasksCount: number;
/**
* Only persistent tasks that are incomplete.
* Used for sync logic - dynamic tasks must NEVER be synced.
*/
incompletePersistentTasks: HatchTask[];
/**
* Only persistent tasks that are complete.
* Used for sync logic.
*/
completedPersistentTasks: HatchTask[];
/**
* Stable string key of completed persistent task IDs.
* Used for sync anti-loop protection.
*/
completedPersistentTaskIds: string;
/**
* Tasks to sync (persistent only, with completion status).
* Dynamic tasks are excluded.
*/
tasksToSync: Array<{ taskId: string; completed: boolean }>;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Filter tasks to only persistent tasks.
* Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
/**
* Filter tasks to only dynamic tasks.
*/
export function filterDynamicTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'dynamic');
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook that provides a unified interface for the active task process.
*
* Usage:
* ```ts
* const taskProcess = useActiveTaskProcess(companion, hatchTasks, evolveTasks);
*
* // Access unified data
* taskProcess.config.type // 'hatch' | 'evolve' | null
* taskProcess.tasks // current tasks
* taskProcess.remainingTasksCount // for badge (includes dynamic)
* taskProcess.tasksToSync // for sync (excludes dynamic)
* ```
*/
export function useActiveTaskProcess(
companion: BlobbiCompanion | null,
hatchTasks: HatchTasksResult,
evolveTasks: EvolveTasksResult
): ActiveTaskProcessResult {
// Determine which process is active
const processType = useMemo((): TaskProcessType => {
if (!companion) return null;
if (companion.state === 'incubating') return 'hatch';
if (companion.state === 'evolving') return 'evolve';
return null;
}, [companion]);
// Build configuration
const config = useMemo((): TaskProcessConfig => {
const isActive = processType !== null;
const interactionThreshold = processType === 'hatch'
? HATCH_REQUIRED_INTERACTIONS
: processType === 'evolve'
? EVOLVE_REQUIRED_INTERACTIONS
: 0;
return {
type: processType,
isActive,
interactionThreshold,
};
}, [processType]);
// Get the active tasks result based on process type
const activeResult = useMemo(() => {
if (processType === 'hatch') return hatchTasks;
if (processType === 'evolve') return evolveTasks;
return null;
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
const dynamicTaskComplete = activeResult?.dynamicTaskComplete ?? false;
const refetch = activeResult?.refetch ?? (() => {});
// Compute persistent task list (dynamic tasks computed for badge count directly from tasks array)
const persistentTasks = useMemo(() => filterPersistentTasks(tasks), [tasks]);
// Compute incomplete tasks (for badge - includes BOTH persistent and dynamic)
const remainingTasksCount = useMemo(() => {
// Count ALL incomplete tasks - persistent AND dynamic
// Dynamic tasks are included in badge count but NEVER synced to tags
return tasks.filter(t => !t.completed).length;
}, [tasks]);
// Compute persistent task lists for sync
const incompletePersistentTasks = useMemo(() =>
persistentTasks.filter(t => !t.completed),
[persistentTasks]
);
const completedPersistentTasks = useMemo(() =>
persistentTasks.filter(t => t.completed),
[persistentTasks]
);
// Compute stable string key for completed persistent tasks (anti-loop)
const completedPersistentTaskIds = useMemo(() => {
if (!completedPersistentTasks.length) return '';
return completedPersistentTasks
.map(t => t.id)
.sort()
.join(',');
}, [completedPersistentTasks]);
// Compute tasks to sync (persistent only)
// CRITICAL: Dynamic tasks must NEVER be included here
const tasksToSync = useMemo(() => {
if (!persistentTasks.length) return [];
return persistentTasks.map(t => ({
taskId: t.id,
completed: t.completed,
}));
}, [persistentTasks]);
return {
config,
tasks,
isLoading,
allCompleted,
persistentTasksComplete,
dynamicTaskComplete,
refetch,
remainingTasksCount,
incompletePersistentTasks,
completedPersistentTasks,
completedPersistentTaskIds,
tasksToSync,
};
}
@@ -0,0 +1,287 @@
// src/blobbi/actions/hooks/useAudioPlayback.ts
import { useState, useRef, useCallback, useEffect } from 'react';
/**
* Audio playback state
* - idle: No audio loaded
* - loading: Audio is being loaded
* - playing: Audio is playing
* - paused: Audio is paused (can resume)
* - stopped: Audio was stopped (must reload to play again)
* - error: An error occurred
*/
export type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
/**
* Audio playback error info
*/
export interface PlaybackError {
message: string;
code?: string;
}
/** Default volume level (0-1) */
const DEFAULT_VOLUME = 0.8;
/**
* Options for the useAudioPlayback hook
*/
export interface UseAudioPlaybackOptions {
/** Called when playback ends naturally */
onEnded?: () => void;
/** Called when an error occurs */
onError?: (error: PlaybackError) => void;
/** Initial volume level (0-1), defaults to 0.8 */
initialVolume?: number;
}
/**
* Return type for useAudioPlayback hook
*/
export interface UseAudioPlaybackReturn {
/** Current playback state */
state: PlaybackState;
/** Current error (if any) */
error: PlaybackError | null;
/** Current audio URL being played */
currentUrl: string | null;
/** Load and optionally start playing an audio URL */
load: (url: string, autoplay?: boolean) => void;
/** Play the current audio */
play: () => Promise<void>;
/** Pause the current audio */
pause: () => void;
/** Stop playback and reset */
stop: () => void;
/** Restart playback from the beginning */
restart: () => Promise<void>;
/** Toggle play/pause */
toggle: () => Promise<void>;
/** Whether audio is currently playing */
isPlaying: boolean;
/** Current volume level (0-1) */
volume: number;
/** Set volume level (0-1) */
setVolume: (volume: number) => void;
/** Cleanup function to release resources */
cleanup: () => void;
}
/**
* Reusable hook for audio playback.
* Handles Audio element lifecycle, error handling, and state management.
*/
export function useAudioPlayback(options: UseAudioPlaybackOptions = {}): UseAudioPlaybackReturn {
const { onEnded, onError, initialVolume = DEFAULT_VOLUME } = options;
const [state, setState] = useState<PlaybackState>('idle');
const [error, setError] = useState<PlaybackError | null>(null);
const [currentUrl, setCurrentUrl] = useState<string | null>(null);
const [volume, setVolumeState] = useState<number>(initialVolume);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentUrlRef = useRef<string | null>(null);
const volumeRef = useRef<number>(initialVolume);
// Cleanup audio element
const cleanup = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
audioRef.current = null;
}
currentUrlRef.current = null;
setState('idle');
setCurrentUrl(null);
setError(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Load audio from URL
const load = useCallback((url: string, autoplay = false) => {
// If same URL, don't reload
if (currentUrlRef.current === url && audioRef.current) {
if (autoplay) {
audioRef.current.play().catch(() => {});
}
return;
}
// Cleanup previous audio
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.onended = null;
audioRef.current.onerror = null;
audioRef.current.oncanplay = null;
audioRef.current.onplaying = null;
}
setState('loading');
setError(null);
setCurrentUrl(url);
currentUrlRef.current = url;
const audio = new Audio(url);
audio.volume = volumeRef.current; // Apply current volume to new audio
audioRef.current = audio;
audio.oncanplay = () => {
if (autoplay) {
audio.play().catch((err) => {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err.name,
};
setError(playbackError);
setState('error');
onError?.(playbackError);
});
} else {
setState('paused');
}
};
audio.onplaying = () => {
setState('playing');
};
audio.onpause = () => {
if (state === 'playing') {
setState('paused');
}
};
audio.onended = () => {
setState('paused');
onEnded?.();
};
audio.onerror = () => {
const playbackError: PlaybackError = {
message: 'Failed to load audio. The format may not be supported.',
code: 'MEDIA_ERR',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
};
// Start loading
audio.load();
}, [onEnded, onError, state]);
// Play current audio
const play = useCallback(async () => {
if (!audioRef.current) return;
try {
setError(null);
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Pause current audio
const pause = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
setState('paused');
}, []);
// Stop playback completely (requires reload to play again)
const stop = useCallback(() => {
if (!audioRef.current) return;
audioRef.current.pause();
audioRef.current.currentTime = 0;
// Clear URL ref so next load() will actually reload
currentUrlRef.current = null;
setState('stopped');
}, []);
// Restart playback from the beginning
const restart = useCallback(async () => {
if (!audioRef.current) return;
audioRef.current.currentTime = 0;
try {
await audioRef.current.play();
setState('playing');
} catch (err) {
const playbackError: PlaybackError = {
message: getPlaybackErrorMessage(err),
code: err instanceof Error ? err.name : 'UNKNOWN',
};
setError(playbackError);
setState('error');
onError?.(playbackError);
}
}, [onError]);
// Toggle play/pause
const toggle = useCallback(async () => {
if (state === 'playing') {
pause();
} else {
await play();
}
}, [state, play, pause]);
// Set volume (0-1)
const setVolume = useCallback((newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume));
volumeRef.current = clampedVolume;
setVolumeState(clampedVolume);
if (audioRef.current) {
audioRef.current.volume = clampedVolume;
}
}, []);
return {
state,
error,
currentUrl,
load,
play,
pause,
stop,
restart,
toggle,
isPlaying: state === 'playing',
volume,
setVolume,
cleanup,
};
}
/**
* Get a user-friendly error message for playback errors
*/
function getPlaybackErrorMessage(err: unknown): string {
if (err instanceof Error) {
if (err.name === 'NotSupportedError') {
return 'This audio format is not supported by your browser.';
}
if (err.name === 'NotAllowedError') {
return 'Playback was blocked. Try interacting with the page first.';
}
return err.message;
}
return 'An unknown error occurred during playback.';
}
@@ -0,0 +1,200 @@
/**
* useBlobbiCareActivity - Hook for registering care activity and updating streaks
*
* This hook provides a centralized way to register care activity for a Blobbi companion.
* It handles:
* - Calculating streak updates based on the last activity day
* - Publishing updated Blobbi state to Nostr
* - Updating local cache
*
* Use this hook whenever care activity should count toward the streak:
* - Opening the Blobbi page (page check-in)
* - Performing care actions (feed, clean, play, etc.)
* - Any other care interaction
*
* The streak only increments once per calendar day, regardless of how many
* activities are performed.
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
export interface CareActivityResult {
/** Whether the streak was updated */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** Description of what happened */
action: StreakUpdateResult['action'];
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to register care activity and update streaks.
*
* Returns a function to register activity and a mutation for the actual update.
* The register function is idempotent - calling it multiple times on the same day
* will only update once.
*/
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
}: UseBlobbiCareActivityParams) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
// Track if we've already registered activity this session to avoid duplicate calls
// This is a performance optimization - the actual idempotency is handled by day comparison
const lastRegisteredDay = useRef<string | null>(null);
const mutation = useMutation({
mutationFn: async (): Promise<CareActivityResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to register care activity');
}
if (!companion) {
throw new Error('No companion available');
}
// Fetch fresh companion from relays (read-modify-write pattern)
const freshEvents = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [companion.d],
}]);
const freshCompanion = freshEvents
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at)
.map(e => parseBlobbiEvent(e))
.find(Boolean) ?? companion;
const now = new Date();
// Calculate what the streak update should be using fresh data
const result = calculateStreakUpdate(
freshCompanion.careStreak,
freshCompanion.careStreakLastDay,
now
);
// If no update needed (same day), return early without publishing
if (!result.wasUpdated) {
return {
wasUpdated: false,
newStreak: result.newStreak,
action: result.action,
};
}
// Get the tag updates using fresh data
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: freshCompanion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags from fresh data
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: freshCompanion.event.content,
tags: updatedTags,
});
// Update local cache (optimistic — no invalidation needed)
updateCompanionEvent(event);
// Update session tracker
lastRegisteredDay.current = result.newLastDay;
// Log for debugging (dev only)
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: freshCompanion.careStreak,
newStreak: result.newStreak,
lastDay: freshCompanion.careStreakLastDay,
newDay: result.newLastDay,
});
}
return {
wasUpdated: true,
newStreak: result.newStreak,
action: result.action,
};
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
});
/**
* Register care activity. Call this when care-related activity happens.
* Safe to call multiple times - only updates streak once per day.
*
* @returns Promise with the result of the activity registration
*/
const registerCareActivity = useCallback(async (): Promise<CareActivityResult | null> => {
if (!companion) {
return null;
}
// Quick check if we've already registered for this companion's last day (session cache)
// This is an optimization to avoid unnecessary mutation calls
if (lastRegisteredDay.current === companion.careStreakLastDay) {
// Already processed this day in this session, skip
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
return mutation.mutateAsync();
}, [companion, mutation]);
return {
/** Register care activity - call when page opens or care action happens */
registerCareActivity,
/** Whether an update is currently in progress */
isUpdating: mutation.isPending,
/** The last update result */
lastResult: mutation.data,
/** Any error from the last update attempt */
error: mutation.error,
};
}
@@ -0,0 +1,216 @@
// src/blobbi/actions/hooks/useBlobbiDirectAction.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import {
clampStat,
applyStat,
DIRECT_ACTION_METADATA,
incrementInteractionTaskTags,
type DirectAction,
} from '../lib/blobbi-action-utils';
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';
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Configuration for direct action happiness effects.
* These are the happiness deltas for each direct action.
*/
export const DIRECT_ACTION_HAPPINESS_EFFECTS: Record<DirectAction, number> = {
play_music: 15,
sing: 20,
};
/**
* Request payload for executing a direct action
*/
export interface DirectActionRequest {
action: DirectAction;
}
/**
* Result of executing a direct action
*/
export interface DirectActionResult {
action: DirectAction;
happinessChange: number;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiDirectAction hook
*/
export interface UseBlobbiDirectActionParams {
companion: BlobbiCompanion | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't require selecting an item.
* They directly affect happiness stat.
*
* This hook:
* 1. Validates the companion exists
* 2. Ensures canonical format before action
* 3. Applies accumulated decay
* 4. Applies happiness boost
* 5. Updates Blobbi state (kind 31124)
* 6. Invalidates relevant queries
*/
export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ action }: DirectActionRequest): Promise<DirectActionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to perform actions');
}
if (!companion) {
throw new Error('No companion selected');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
const statsAfterDecay = decayResult.stats;
// ─── Apply Happiness Effect ───
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> = {
happiness: newHappiness.toString(),
health: statsAfterDecay.health.toString(),
hygiene: statsAfterDecay.hygiene.toString(),
};
if (isEgg) {
// Eggs have fixed hunger and energy
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
statsUpdate.hunger = clampStat(statsAfterDecay.hunger).toString();
statsUpdate.energy = clampStat(statsAfterDecay.energy).toString();
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// 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,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
return {
action,
happinessChange: happinessDelta,
xpGained,
newXP,
};
},
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}! ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'sing') dailyActions.push('sing');
if (action === 'play_music') dailyActions.push('play_music');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Action failed',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,880 @@
// src/blobbi/actions/hooks/useBlobbiIncubation.ts
/**
* Hooks for Blobbi incubation task system.
*
* When a user starts incubation:
* 1. Apply accumulated decay from last_decay_at to now
* 2. Set state to 'incubating'
* 3. Add state_started_at timestamp
* 4. Update last_decay_at to the same timestamp
* 5. Clear any previous task progress
*
* Tasks are computed from Nostr events with created_at >= state_started_at
*/
import { useMutation } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Mode for starting incubation.
* This makes the intent explicit rather than auto-detecting behavior.
*/
export type StartIncubationMode =
| 'start' // Normal start (no other Blobbi incubating)
| 'restart' // Restart same Blobbi (already incubating)
| 'switch'; // Switch from another incubating Blobbi
/**
* Request to start incubation with explicit mode.
*/
export interface StartIncubationRequest {
/** Explicit mode for this operation */
mode: StartIncubationMode;
/** The d-tag of the other Blobbi to stop (required when mode === 'switch') */
stopOtherD?: string;
}
/**
* Parameters for start incubation hook.
*/
export interface UseStartIncubationParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of starting incubation.
*/
export interface StartIncubationResult {
/** The Blobbi's name */
name: string;
/** Timestamp when incubation started */
stateStartedAt: number;
/** Mode that was used */
mode: StartIncubationMode;
/** Name of other Blobbi that was stopped (if mode === 'switch') */
stoppedOtherName?: string;
}
// ─── Start Incubation Hook ────────────────────────────────────────────────────
/**
* Hook to start the incubation process for an egg.
*
* This sets the Blobbi state to 'incubating' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* IMPORTANT: The mode must be explicitly specified by the caller (UI).
* This hook does NOT auto-detect whether to switch or restart.
* The UI dialog determines the mode and passes it explicitly.
*
* Modes:
* - 'start': Normal start, no other Blobbi incubating
* - 'restart': Restart same Blobbi (already incubating), resets task progress
* - 'switch': Stop another Blobbi first, then start this one
*
* Requirements:
* - Blobbi must be in egg stage
* - User must be logged in
*/
export function useStartIncubation({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (request: StartIncubationRequest): Promise<StartIncubationResult> => {
const { mode, stopOtherD } = request;
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be incubated');
}
// Validate switch mode requires stopOtherD
if (mode === 'switch' && !stopOtherD) {
throw new Error('Switch mode requires stopOtherD parameter');
}
let stoppedOtherName: string | undefined;
// ─── Stop Other Incubating Blobbi (switch mode only) ───
if (mode === 'switch' && stopOtherD) {
// Fetch the current event for the other Blobbi
const [otherEvent] = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [stopOtherD],
limit: 1,
}]);
if (otherEvent) {
// Get name from the event for the result
const nameTag = otherEvent.tags.find(t => t[0] === 'name');
stoppedOtherName = nameTag?.[1] ?? stopOtherD;
// Stop the other Blobbi's incubation
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
// Parse stats from the event
const getTagValue = (tags: string[][], name: string): number =>
parseInt(tags.find(t => t[0] === name)?.[1] ?? '50', 10);
const otherStats = {
hunger: getTagValue(otherEvent.tags, 'hunger'),
happiness: getTagValue(otherEvent.tags, 'happiness'),
health: getTagValue(otherEvent.tags, 'health'),
hygiene: getTagValue(otherEvent.tags, 'hygiene'),
energy: getTagValue(otherEvent.tags, 'energy'),
};
const otherLastDecayAt = getTagValue(otherEvent.tags, 'last_decay_at') || now;
// Apply decay to the other Blobbi
const otherDecayResult = applyBlobbiDecay({
stage: 'egg',
state: 'incubating',
stats: otherStats,
lastDecayAt: otherLastDecayAt,
now,
});
// Remove task tags and state_started_at from the other Blobbi
const otherCleanedTags = otherEvent.tags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
const otherNewTags = updateBlobbiTags(otherCleanedTags, {
health: otherDecayResult.stats.health.toString(),
hygiene: otherDecayResult.stats.hygiene.toString(),
happiness: otherDecayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// Publish the stop event for the other Blobbi
const stopEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: otherEvent.content,
tags: otherNewTags,
});
// Update the cache for the stopped Blobbi
updateCompanionEvent(stopEvent);
}
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for incubation');
}
// ─── Apply Accumulated Decay ───
// CRITICAL: Apply decay from last_decay_at to now before changing state
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh (for all modes)
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'incubating',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
return {
name: canonical.companion.name,
stateStartedAt: now,
mode,
stoppedOtherName,
};
},
onSuccess: ({ name, mode, stoppedOtherName }) => {
if (mode === 'switch' && stoppedOtherName) {
toast({
title: 'Switched incubation!',
description: `Stopped ${stoppedOtherName}, now incubating ${name}.`,
});
} else if (mode === 'restart') {
toast({
title: 'Incubation restarted!',
description: `${name}'s task progress has been reset.`,
});
} else {
toast({
title: 'Incubation started!',
description: `${name} is now incubating. Complete the tasks to hatch!`,
});
}
},
onError: (error: Error) => {
toast({
title: 'Failed to start incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Incubation Hook ─────────────────────────────────────────────────────
/**
* Parameters for stop incubation hook.
*/
export interface UseStopIncubationParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of stopping incubation.
*/
export interface StopIncubationResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the incubation process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart incubation later, but will need to complete tasks again.
*
* When stopping incubation:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in incubating state
* - User must be logged in
*/
export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopIncubationResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop incubation');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'incubating') {
throw new Error('This Blobbi is not incubating');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
// Eggs have fixed hunger and energy at 100
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: '100',
energy: '100',
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Incubation stopped',
description: `${name} is no longer incubating. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Start Evolution Hook ─────────────────────────────────────────────────────
/**
* Parameters for start evolution hook.
*/
export interface UseStartEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of starting evolution.
*/
export interface StartEvolutionResult {
/** The Blobbi's name */
name: string;
/** Timestamp when evolution started */
stateStartedAt: number;
}
/**
* Hook to start the evolution process for a baby Blobbi.
*
* This sets the Blobbi state to 'evolving' and records the start timestamp.
* Tasks will be computed based on events created after this timestamp.
*
* Requirements:
* - Blobbi must be in baby stage
* - Blobbi must not already be evolving
* - User must be logged in
*/
export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StartEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to start evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.stage !== 'baby') {
throw new Error('Only baby Blobbis can evolve');
}
if (companion.state === 'evolving') {
throw new Error('This Blobbi is already evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove any existing task tags when starting fresh
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' && tag[0] !== 'task_completed'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'evolving',
state_started_at: nowStr,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
return {
name: canonical.companion.name,
stateStartedAt: now,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution started!',
description: `${name} is now working towards evolution. Complete the tasks to evolve!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to start evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Stop Evolution Hook ──────────────────────────────────────────────────────
/**
* Parameters for stop evolution hook.
*/
export interface UseStopEvolutionParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of stopping evolution.
*/
export interface StopEvolutionResult {
/** The Blobbi's name */
name: string;
}
/**
* Hook to stop/cancel the evolution process for a Blobbi.
*
* This resets the Blobbi state to 'active' and clears all task progress tags.
* The user can restart evolution later, but will need to complete tasks again.
*
* When stopping evolution:
* - Apply accumulated decay first
* - Set state back to 'active'
* - Remove state_started_at tag
* - Remove all task and task_completed tags
*
* Requirements:
* - Blobbi must be in evolving state
* - User must be logged in
*/
export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StopEvolutionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to stop evolution');
}
if (!companion) {
throw new Error('No companion selected');
}
if (companion.state !== 'evolving') {
throw new Error('This Blobbi is not evolving');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion');
}
// ─── Apply Accumulated Decay ───
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Build Updated Tags ───
// Remove task tags and state_started_at
const cleanedTags = canonical.allTags.filter(tag =>
tag[0] !== 'task' &&
tag[0] !== 'task_completed' &&
tag[0] !== 'state_started_at'
);
// Build stats update with decayed values
const statsUpdate: Record<string, string> = {
health: decayResult.stats.health.toString(),
hygiene: decayResult.stats.hygiene.toString(),
happiness: decayResult.stats.happiness.toString(),
hunger: decayResult.stats.hunger.toString(),
energy: decayResult.stats.energy.toString(),
};
const newTags = updateBlobbiTags(cleanedTags, {
...statsUpdate,
state: 'active',
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: newTags,
});
updateCompanionEvent(event);
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution stopped',
description: `${name} is no longer evolving. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop evolution',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_TASK_SYNC = import.meta.env.DEV;
/**
* Parameters for syncing task completions (works for both hatch and evolve).
*/
export interface UseSyncTaskCompletionsParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Task completions to sync (from useHatchTasks or useEvolveTasks).
*/
export interface TaskCompletionToSync {
taskId: string;
completed: boolean;
}
/**
* Result of sync operation.
*/
export interface SyncTaskCompletionsResult {
/** Task IDs that were synced (empty if nothing needed) */
synced: string[];
/** Whether sync was skipped (no diff) */
skipped: boolean;
/** Reason for skip (for debugging) */
skipReason?: string;
}
/**
* Hook to sync persistent task completions to kind 31124 tags.
* Works for both hatch (incubating) and evolve (evolving) processes.
*
* CRITICAL: This is a cache-only sync. It must be:
* 1. Fully idempotent - calling multiple times with same data = no-op
* 2. Diff-based - only publish when tags would actually change
* 3. Safe - no last_interaction update (this is cache sync, not user action)
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
*
* Source of truth = computed task state from Nostr events.
* Tags = cache layer for faster access.
*/
export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
// ─── Early Guards ───
if (!user?.pubkey) {
return { synced: [], skipped: true, skipReason: 'no_user' };
}
if (!companion) {
return { synced: [], skipped: true, skipReason: 'no_companion' };
}
// Must be in an active task process (incubating or evolving)
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
}
// ─── Compute Diff ───
// Get cached completions from companion.tasksCompleted (parsed from tags)
const cachedCompletions = new Set(companion.tasksCompleted);
// Get computed completions from tasks (works for both hatch and evolve)
const computedCompletions = tasksToSync
.filter(t => t.completed)
.map(t => t.taskId);
// Find tasks that are computed as complete but NOT in cache
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Diff check:', {
cachedCompletions: Array.from(cachedCompletions),
computedCompletions,
missingFromCache,
});
}
// If no diff, skip entirely
if (missingFromCache.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: no diff between computed and cached');
}
return { synced: [], skipped: true, skipReason: 'no_diff' };
}
// ─── Ensure Canonical ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
}
// ─── Build Updated Tags ───
// Re-check against canonical.allTags (may have updated since companion was parsed)
const existingCompletionTags = new Set(
canonical.allTags
.filter(tag => tag[0] === 'task_completed')
.map(tag => tag[1])
);
// Filter to only truly missing tags
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
if (tagsToAdd.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: all tags already exist in canonical');
}
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
}
// Add only the missing task_completed tags
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
const updatedTags = [
...canonical.allTags,
...tagsToAdd.map(id => ['task_completed', id]),
];
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Publishing:', {
tagsToAdd,
totalTags: updatedTags.length,
});
}
// ─── Publish ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: updatedTags,
});
updateCompanionEvent(event);
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
}
return { synced: tagsToAdd, skipped: false };
},
});
}
@@ -0,0 +1,387 @@
// src/blobbi/actions/hooks/useBlobbiStageTransition.ts
/**
* Hooks for Blobbi stage transitions (hatch, evolve).
*
* Both transitions follow the same decay pattern:
* 1. Apply accumulated decay from `last_decay_at` to `now`
* 2. Use decayed stats as the source of truth for the transition
* 3. Publish new event with decayed stats + new stage
* 4. Reset `last_decay_at` to current timestamp
*
* @see docs/blobbi/decay-system.md
*/
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
STAT_MAX,
updateBlobbiTags,
} 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 ──────────────────────────────────────────────────────────
/**
* Generate the content string for a Blobbi at a given stage.
* Format: "{name} is a {stage} Blobbi."
*
* Uses correct grammar: "an egg" vs "a baby/adult"
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of ensuring canonical companion before action.
* This is the same interface used by useBlobbiUseInventoryItem.
*/
export interface CanonicalActionResult {
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
}
/**
* Parameters for stage transition hooks.
*/
export interface UseBlobbiStageTransitionParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called to ensure companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
/**
* Result of a stage transition.
*/
export interface StageTransitionResult {
/** Previous stage before transition */
previousStage: BlobbiStage;
/** New stage after transition */
newStage: BlobbiStage;
/** The Blobbi's name */
name: string;
/** Stats after decay was applied (before any transition bonuses) */
decayedStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
}
// ─── Hatch Hook ───────────────────────────────────────────────────────────────
/**
* Hook to hatch an egg into a baby Blobbi.
*
* Transition: egg -> baby
*
* Requirements:
* - Blobbi must be in egg stage
* - Applies accumulated decay before transition
* - Resets stats to healthy baby defaults (inherits health from egg)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiHatch({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to hatch');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'egg') {
throw new Error('Only eggs can be hatched');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for hatching');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Calculate Baby Stats ───
// All stats reset to 100 when hatching — the baby starts fresh
const babyStats = {
hunger: STAT_MAX,
happiness: STAT_MAX,
health: STAT_MAX,
hygiene: STAT_MAX,
energy: STAT_MAX,
};
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Build the updated tags using the central merge function
// Get streak updates (hatching counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'baby',
state: 'active', // Newly hatched babies are awake
hunger: babyStats.hunger.toString(),
happiness: babyStats.happiness.toString(),
health: babyStats.health.toString(),
hygiene: babyStats.hygiene.toString(),
energy: babyStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Hatch] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Baby Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'baby');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
return {
previousStage: 'egg',
newStage: 'baby',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Your egg hatched!',
description: `${name} is now a baby Blobbi! Take good care of them.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to hatch',
description: error.message,
variant: 'destructive',
});
},
});
}
// ─── Evolve Hook ──────────────────────────────────────────────────────────────
/**
* Hook to evolve a baby Blobbi into an adult.
*
* Transition: baby -> adult
*
* Requirements:
* - Blobbi must be in baby stage
* - Applies accumulated decay before transition
* - Preserves all stats (decay already applied)
* - Sets last_decay_at to current timestamp
*/
export function useBlobbiEvolve({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (): Promise<StageTransitionResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to evolve');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
if (companion.stage !== 'baby') {
if (companion.stage === 'egg') {
throw new Error('Eggs must hatch before they can evolve');
}
if (companion.stage === 'adult') {
throw new Error('This Blobbi is already fully evolved');
}
throw new Error('Only baby Blobbis can evolve');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for evolution');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any stage transition.
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// ─── Adult Stats ───
// Adult inherits all decayed stats from baby
// No stat reset - evolution preserves current condition
const adultStats = decayResult.stats;
// ─── Build Updated Tags ───
// CRITICAL: Start from canonical.allTags and only remove task/state-specific tags
// This preserves ALL identity attributes (personality, trait, favorite_food, etc.)
const nowStr = now.toString();
// Get streak updates (evolving counts as care activity!)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// Build the updated tags using the central merge function
const mergedTags = updateBlobbiTags(canonical.allTags, {
stage: 'adult',
state: 'active', // Evolution completes with active state
hunger: adultStats.hunger.toString(),
happiness: adultStats.happiness.toString(),
health: adultStats.health.toString(),
hygiene: adultStats.hygiene.toString(),
energy: adultStats.energy.toString(),
...streakUpdates,
last_interaction: nowStr,
last_decay_at: nowStr,
});
// ─── Validate and Repair Tags ───
// Use the tag integrity guard to ensure all persistent tags are preserved
// and task-related tags are properly cleaned up for stage transitions
const repairResult = validateAndRepairBlobbiTags(
mergedTags,
canonical.allTags,
{ cleanupTaskTags: true }
);
if (repairResult.errors.length > 0) {
console.error('[Evolve] Tag validation errors:', repairResult.errors);
throw new Error(`Tag validation failed: ${repairResult.errors.join(', ')}`);
}
if (repairResult.repaired && import.meta.env.DEV) {
console.log('[Evolve] Tag repairs applied:', repairResult.repairs);
}
const newTags = repairResult.tags;
// ─── Generate New Content for Adult Stage ───
// CRITICAL: Content must reflect the new stage
const newContent = generateBlobbiContent(canonical.companion.name, 'adult');
// ─── Publish Event ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: newContent,
tags: newTags,
});
updateCompanionEvent(event);
return {
previousStage: 'baby',
newStage: 'adult',
name: canonical.companion.name,
decayedStats: decayResult.stats,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Evolution complete!',
description: `${name} has evolved into an adult Blobbi!`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to evolve',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,315 @@
// src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
canUseAction,
getStageRestrictionMessage,
clampStat,
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
incrementInteractionTaskTags,
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
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';
/**
* Request payload for using an item on a Blobbi companion
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
}
/**
* Result of using an item on a Blobbi companion
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
}
/**
* Parameters for the useBlobbiUseInventoryItem hook
*/
export interface UseBlobbiUseInventoryItemParams {
companion: BlobbiCompanion | null;
profile: BlobbonautProfile | null;
/** Called after ensuring companion is canonical (from migration helper) */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
}
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog — no
* inventory ownership or quantity is required.
*
* This hook:
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent: _updateProfileEvent,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
}
if (!companion) {
throw new Error('No companion selected');
}
if (!profile) {
throw new Error('Profile not found');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
throw new Error(message ?? 'This companion cannot use this item');
}
// Validate item exists in shop catalog
const shopItem = getShopItemById(itemId);
if (!shopItem) {
throw new Error('Item not found in catalog');
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
}
// For eggs, validate that items have applicable effects
const isEgg = companion.stage === 'egg';
if (isEgg && action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
throw new Error('This medicine has no effect on eggs');
}
if (isEgg && action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect)) {
throw new Error('This item has no cleaning effect on eggs');
}
// ─── Ensure Canonical Before Action ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
throw new Error('Failed to prepare companion for action');
}
// ─── Apply Accumulated Decay First ───
// Per decay-system.md: Always apply accumulated decay from persisted state
// before any user interaction updates stats.
// CRITICAL: Use canonical.companion for decay calculations, not the stale outer companion
const now = Math.floor(Date.now() / 1000);
const decayResult = applyBlobbiDecay({
stage: canonical.companion.stage,
state: canonical.companion.state,
stats: canonical.companion.stats,
lastDecayAt: canonical.companion.lastDecayAt,
now,
});
// 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 (single use) ───
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
statsUpdate.happiness = currentHappiness.toString();
const totalHappinessChange = currentHappiness - (statsAfterDecay.happiness ?? 0);
if (totalHappinessChange !== 0) {
statsChanged.happiness = totalHappinessChange;
}
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
statsUpdate.happiness = clampStat(currentStats.happiness).toString();
statsChanged.happiness = (currentStats.happiness ?? 0) - (statsAfterDecay.happiness ?? 0);
statsUpdate.energy = clampStat(currentStats.energy).toString();
statsChanged.energy = (currentStats.energy ?? 0) - (statsAfterDecay.energy ?? 0);
statsUpdate.hygiene = clampStat(currentStats.hygiene).toString();
statsChanged.hygiene = (currentStats.hygiene ?? 0) - (statsAfterDecay.hygiene ?? 0);
statsUpdate.health = clampStat(currentStats.health).toString();
statsChanged.health = (currentStats.health ?? 0) - (statsAfterDecay.health ?? 0);
}
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
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,
});
const blobbiEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: blobbiTags,
});
updateCompanionEvent(blobbiEvent);
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
action,
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
// 'interact' is always tracked, plus the specific action if it maps to a daily mission
const dailyActions: DailyMissionAction[] = ['interact'];
if (action === 'feed') dailyActions.push('feed');
if (action === 'clean') dailyActions.push('clean');
trackMultipleDailyMissionActions(dailyActions, user?.pubkey);
},
onError: (error: Error) => {
toast({
title: 'Failed to use item',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -0,0 +1,121 @@
/**
* useAwardDailyXp - Award XP for completed daily missions
*
* Completion is implicit (derived from progress vs target).
* This hook calculates the total XP earned today and persists
* the updated XP total to kind 11125 tags.
*
* Uses fetchFreshEvent to avoid stale-read overwrites when
* multiple mutations race (e.g. item use XP + daily XP).
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
parseBlobbonautEvent,
} from '@/blobbi/core/lib/blobbi';
import { buildXpTagUpdates } from '@/blobbi/core/lib/progression';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { totalDailyXp } from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AwardDailyXpRequest {
/** Current missions state to calculate XP from */
missions: MissionsContent;
}
export interface AwardDailyXpResult {
xpAwarded: number;
newTotalXp: number;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to award XP for completed daily missions.
*
* @param updateProfileEvent - Callback to update profile in query cache
*/
export function useAwardDailyXp(
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ missions }: AwardDailyXpRequest): Promise<AwardDailyXpResult> => {
if (!user?.pubkey) throw new Error('Must be logged in');
const xpToAward = totalDailyXp(missions);
if (xpToAward <= 0) return { xpAwarded: 0, newTotalXp: 0 };
// Fetch fresh profile from relays to avoid stale-read overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [user.pubkey],
});
const freshProfile = prev ? parseBlobbonautEvent(prev) : undefined;
const currentXp = freshProfile?.xp ?? 0;
const newTotalXp = currentXp + xpToAward;
// Update XP and level tags on the fresh event's tags
const updatedTags = updateBlobbonautTags(
prev?.tags ?? [],
buildXpTagUpdates(newTotalXp),
);
// Persist missions state to content field
const content = serializeProfileContent(
prev?.content ?? '',
{ missions },
);
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content,
tags: updatedTags,
prev: prev ?? undefined,
});
updateProfileEvent(event);
return { xpAwarded: xpToAward, newTotalXp };
},
onSuccess: ({ xpAwarded }) => {
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
if (xpAwarded > 0) {
toast({
title: 'XP Earned!',
description: `You earned ${xpAwarded} XP from daily missions.`,
});
}
},
onError: (error: Error) => {
toast({
title: 'Failed to Award XP',
description: error.message,
variant: 'destructive',
});
},
});
}
// Legacy export name for backward compatibility during migration
export const useClaimMissionReward = useAwardDailyXp;
export type ClaimMissionRequest = AwardDailyXpRequest;
export type ClaimMissionResult = AwardDailyXpResult;
@@ -0,0 +1,209 @@
/**
* useDailyMissions - Hook for reading daily mission state
*
* Provides reactive access to the current day's missions.
* Progress tracking is done via the tracker module (non-React).
* Completion is implicit (derived from count/events vs target).
* XP is awarded automatically when missions complete.
*
* State lives in a pubkey-scoped in-memory Map. On mount or account
* switch, hydrates from kind 11125 content JSON if the session store
* is empty. Completed missions are persisted by `useAwardDailyXp`;
* intermediate progress resets on page refresh.
*/
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
import { parseProfileContent } from '@/blobbi/core/lib/missions';
import {
type BlobbiStage,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsContent,
areAllDailyComplete,
totalDailyXp,
getDefinition,
MAX_DAILY_REROLLS,
DAILY_BONUS_XP,
} from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
hydrateFromPersisted,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DailyMissionView {
/** Mission ID (matches pool definition) */
id: string;
/** Display title */
title: string;
/** Description */
description: string;
/** Action type */
action: DailyMissionAction;
/** Required count */
target: number;
/** Current progress */
progress: number;
/** Whether mission is complete */
complete: boolean;
/** XP reward */
xp: number;
}
export interface UseDailyMissionsOptions {
/** Available Blobbi stages the user has (filters eligible missions) */
availableStages?: BlobbiStage[];
/**
* Raw content string from the kind 11125 profile event.
* Pass `profile.content` here. The hook parses it to extract
* persisted missions and hydrates the session store on first load.
*/
profileContent?: string;
}
export interface UseDailyMissionsResult {
/** Today's daily missions with computed progress */
missions: DailyMissionView[];
/** The raw missions content (for persistence/mutation hooks) */
raw: MissionsContent | undefined;
/** Whether all daily missions are complete */
allComplete: boolean;
/** Total XP earned today (completed missions + bonus) */
todayXp: number;
/** Whether the daily bonus is unlocked (all missions complete) */
bonusUnlocked: boolean;
/** Bonus XP amount */
bonusXp: number;
/** Whether user has no eligible missions */
noMissionsAvailable: boolean;
/** Rerolls remaining today */
rerollsRemaining: number;
/** Max rerolls per day */
maxRerolls: number;
/** Force refresh missions (testing) */
forceReset: () => void;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
const { availableStages, profileContent } = options;
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Version counter to trigger re-reads from session store
const [version, setVersion] = useState(0);
// Track whether we've hydrated for this pubkey
const hydratedRef = useRef<string | null>(null);
// Hydrate session store from kind 11125 content on mount / account switch
useEffect(() => {
if (!pubkey || !profileContent) return;
if (hydratedRef.current === pubkey) return; // already hydrated this session
// Check if session store already has data for this pubkey
const existing = readMissionsFromStorage(pubkey);
if (existing) {
hydratedRef.current = pubkey;
return;
}
// Parse persisted missions from profile content
const parsed = parseProfileContent(profileContent);
if (parsed.missions && !needsDailyReset(parsed.missions)) {
hydrateFromPersisted(parsed.missions, pubkey);
hydratedRef.current = pubkey;
setVersion((v) => v + 1);
} else {
hydratedRef.current = pubkey;
}
}, [pubkey, profileContent]);
// Listen for tracker events
useEffect(() => {
const handler = () => setVersion((v) => v + 1);
window.addEventListener('daily-missions-updated', handler);
return () => window.removeEventListener('daily-missions-updated', handler);
}, []);
// Stable stages key for deps
const stagesKey = availableStages?.sort().join(',') ?? '';
// Read and ensure current state
const raw = useMemo((): MissionsContent | undefined => {
const stored = readMissionsFromStorage(pubkey);
if (!needsDailyReset(stored)) return stored;
// Reset for new day, preserve evolution missions
const fresh = createDailyMissionsContent(
getTodayDateString(),
stored?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
return fresh;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version, pubkey, stagesKey]);
// Build view models
const missions: DailyMissionView[] = useMemo(() => {
if (!raw?.daily) return [];
return raw.daily.map((m) => {
const def = getDefinition(m.id);
return {
id: m.id,
title: def?.title ?? m.id,
description: def?.description ?? '',
action: def?.action ?? 'interact',
target: m.target,
progress: missionProgress(m),
complete: isMissionComplete(m),
xp: def?.xp ?? 0,
};
});
}, [raw]);
const allComplete = raw ? areAllDailyComplete(raw) : false;
const todayXp = raw ? totalDailyXp(raw) : 0;
const bonusUnlocked = allComplete;
const noMissionsAvailable = missions.length === 0;
const rerollsRemaining = raw?.rerolls ?? MAX_DAILY_REROLLS;
const forceReset = useCallback(() => {
const fresh = createDailyMissionsContent(
getTodayDateString(),
raw?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
setVersion((v) => v + 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey, stagesKey, raw?.evolution]);
return {
missions,
raw,
allComplete,
todayXp,
bonusUnlocked,
bonusXp: DAILY_BONUS_XP,
noMissionsAvailable,
rerollsRemaining,
maxRerolls: MAX_DAILY_REROLLS,
forceReset,
};
}
+363
View File
@@ -0,0 +1,363 @@
// src/blobbi/actions/hooks/useEvolveTasks.ts
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* 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.
*/
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
KIND_PROFILE_METADATA,
KIND_SHORT_TEXT_NOTE,
BLOBBI_POST_REQUIRED_HASHTAGS,
sanitizeToHashtag,
type HatchTask,
type TaskType,
} from './useHatchTasks';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for custom profile tabs event */
export const KIND_PROFILE_TABS = 16769;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
/** Required color moments for evolve task */
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
export const EVOLVE_REQUIRED_POSTS = 1;
/** Required interactions for evolve task */
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
/** Prefix text for Blobbi evolve post */
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
/** Stat threshold for evolve dynamic task (all stats >= 80) */
export const EVOLVE_STAT_THRESHOLD = 80;
// ─── Types ────────────────────────────────────────────────────────────────────
// Re-export task types for convenience
export type { HatchTask as EvolveTask, TaskType };
/**
* Result of computing evolve tasks.
*/
export interface EvolveTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to evolve */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Check if a post is a valid Blobbi evolve post.
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
*/
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with evolve prefix
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute evolve task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create 3 Themes (kind 36767)
* 2. Create 3 Color Moments (kind 3367)
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
* 4. Interact 21 times (tracked via companion.tasks cache)
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 6. Maintain All Stats >= 80
*
* @param companion - The Blobbi companion (must be in evolving state)
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useEvolveTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): EvolveTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isEvolving = companion?.state === 'evolving';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid evolve posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Only need 1 valid evolve post
},
// Custom profile tabs after start
{
kinds: [KIND_PROFILE_TABS],
authors: [pubkey],
since: stateStartedAt,
limit: 1, // Only need 1
},
// Profile metadata after start (for Blobbi shape check + profile edit mission)
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
const profileTabsEvents = events.filter(e =>
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
);
// Get latest profile after start
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
profileTabsEvents,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isEvolving,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create 3 Themes (PERSISTENT)
const themeCount = data?.themeEvents?.length ?? 0;
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
tasks.push({
id: 'create_themes',
name: 'Create Themes',
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
required: EVOLVE_REQUIRED_THEMES,
completed: themesCompleted,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Create 3 Color Moments (PERSISTENT)
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
tasks.push({
id: 'color_moments',
name: 'Color Moments',
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
completed: colorMomentsCompleted,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
const postCount = validPosts.length;
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
tasks.push({
id: 'create_posts',
name: 'Share Evolution',
description: 'Post about your Blobbi evolving',
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
required: EVOLVE_REQUIRED_POSTS,
completed: postsCompleted,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 4. Interact 21 times (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
required: EVOLVE_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
const hasMetadataEdit = !!data?.profileAfter;
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
tasks.push({
id: 'edit_profile',
name: 'Edit Your Profile',
description: 'Update your profile info or customize your profile tabs',
current: hasProfileEdit ? 1 : 0,
required: 1,
completed: hasProfileEdit,
type: 'persistent',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Profile',
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
// 7. Maintain All Stats >= 80
const stats = companion?.stats ?? {};
const hunger = stats.hunger ?? 0;
const happiness = stats.happiness ?? 0;
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const energy = stats.energy ?? 0;
const statsOk =
hunger >= EVOLVE_STAT_THRESHOLD &&
happiness >= EVOLVE_STAT_THRESHOLD &&
health >= EVOLVE_STAT_THRESHOLD &&
hygiene >= EVOLVE_STAT_THRESHOLD &&
energy >= EVOLVE_STAT_THRESHOLD;
// Calculate minimum stat for progress display
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
tasks.push({
id: 'maintain_stats',
name: 'Peak Condition',
description: `Keep all stats above ${EVOLVE_STAT_THRESHOLD}`,
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
required: EVOLVE_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');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count for evolve from companion task cache.
*/
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
+364
View File
@@ -0,0 +1,364 @@
// src/blobbi/actions/hooks/useHatchTasks.ts
/**
* Hook to compute hatch task progress from Nostr events.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached 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';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for theme definition events */
export const KIND_THEME_DEFINITION = 36767;
/** Kind for color moment events (espy.you) */
export const KIND_COLOR_MOMENT = 3367;
/** Kind for profile metadata */
export const KIND_PROFILE_METADATA = 0;
/** Kind for short text notes */
export const KIND_SHORT_TEXT_NOTE = 1;
/** Required interactions to complete the hatch interactions task */
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
// Legacy export for backwards compatibility
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
/**
* Sanitize a name into a valid hashtag format.
* Must match the implementation in BlobbiPostModal.tsx.
*/
export function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Task type classification.
* - persistent: Based on Nostr events, can be cached in tags
* - dynamic: Based on current stats, NEVER stored in tags
*/
export type TaskType = 'persistent' | 'dynamic';
/**
* Individual task definition.
*/
export interface HatchTask {
id: string;
name: string;
description: string;
/** Current progress value */
current: number;
/** Required value for completion */
required: number;
/** Whether the task is complete */
completed: boolean;
/** Task type - persistent (event-based) or dynamic (stat-based) */
type: TaskType;
/** Action to perform (if applicable) */
action?: 'navigate' | 'open_modal' | 'external_link';
/** Target for the action */
actionTarget?: string;
/** Button label */
actionLabel?: string;
}
/**
* Result of computing hatch tasks.
*/
export interface HatchTasksResult {
tasks: HatchTask[];
/** All persistent tasks are complete */
persistentTasksComplete: boolean;
/** Dynamic stat task is complete */
dynamicTaskComplete: boolean;
/** All tasks (persistent + dynamic) are complete - required to hatch */
allCompleted: boolean;
isLoading: boolean;
error: Error | null;
/** Refetch task progress */
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Build the required phrase for a hatch post.
* Format: "Posting to hatch {CapitalizedName} #blobbi"
*/
export function buildHatchPhrase(blobbiName: string): string {
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
}
/**
* Check if a post is a valid Blobbi hatch post.
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
* The user may add extra text before or after it.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name
*/
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
const phrase = buildHatchPhrase(blobbiName);
// The phrase must appear somewhere in the content
if (!event.content.includes(phrase)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present as t tags
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
return hasRequiredHashtags;
}
// Legacy function name for backwards compatibility
export const isValidBlobbiPost = isValidHatchPost;
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute hatch task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create Theme (kind 36767) - ≥1 event after start
* 2. Color Moment (kind 3367) - ≥1 event after start
* 3. Create Post (kind 1) - ≥1 valid Blobbi hatch post after start
* 4. Interactions - 7 total (tracked via companion.tasks cache)
*
* 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
*/
export function useHatchTasks(
companion: BlobbiCompanion | null,
interactionCount?: number
): HatchTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isIncubating = companion?.state === 'incubating';
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid Blobbi posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Reasonable limit
},
// Profile metadata - need both before and after start
// Get latest before start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
until: stateStartedAt,
limit: 1,
},
// Get latest after start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
// Separate profile events into before and after
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileBefore = profileEvents
.filter(e => e.created_at < stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents,
colorMomentEvents,
postEvents,
profileBefore,
profileAfter,
};
},
enabled: !!pubkey && !!stateStartedAt && isIncubating,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create Theme (PERSISTENT)
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
tasks.push({
id: 'create_theme',
name: 'Create Theme',
description: 'Create a custom theme for your profile',
current: hasTheme ? 1 : 0,
required: 1,
completed: hasTheme,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// 2. Color Moment (PERSISTENT)
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
tasks.push({
id: 'color_moment',
name: 'Color Moment',
description: 'Share a color moment on espy',
current: hasColorMoment ? 1 : 0,
required: 1,
completed: hasColorMoment,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create Post (PERSISTENT)
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
const hasValidPost = validPosts.length >= 1;
tasks.push({
id: 'create_post',
name: 'Create Post',
description: 'Share a post about hatching your Blobbi',
current: hasValidPost ? 1 : 0,
required: 1,
completed: hasValidPost,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 5. Interactions (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
required: HATCH_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
dynamicTaskComplete,
allCompleted,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Get the current interaction count from companion task cache.
*/
export function getInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
/**
* Filter tasks to only persistent tasks (for tag sync).
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
*/
export function filterPersistentTasks(tasks: HatchTask[]): HatchTask[] {
return tasks.filter(t => t.type === 'persistent');
}
@@ -0,0 +1,42 @@
/**
* useItemCooldown — React hook for per-item cooldown state.
*
* Subscribes to the shared item-cooldown singleton so components
* re-render when any item's cooldown starts or expires.
*
* Usage:
* ```tsx
* const { isOnCooldown } = useItemCooldown();
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
* ```
*/
import { useCallback, useSyncExternalStore } from 'react';
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
/** Monotonic version counter bumped by the subscription callback. */
let snapshotVersion = 0;
function subscribe(onStoreChange: () => void): () => void {
// subscribeCooldowns returns an unsubscribe function.
// The callback bumps the version AND notifies React.
return subscribeCooldowns(() => {
snapshotVersion++;
onStoreChange();
});
}
function getSnapshot(): number {
return snapshotVersion;
}
export function useItemCooldown() {
useSyncExternalStore(subscribe, getSnapshot);
const isOnCooldown = useCallback((itemId: string): boolean => {
return isItemOnCooldown(itemId);
}, []);
return { isOnCooldown };
}
@@ -0,0 +1,83 @@
/**
* useRerollMission - Replace a daily mission with a new one from the pool
*
* Updates the in-memory session store.
*/
import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import type { BlobbiStage } from '../lib/daily-missions';
import { rerollMission, getDefinition } from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface RerollMissionRequest {
missionId: string;
availableStages?: BlobbiStage[];
}
export interface RerollMissionResult {
oldMissionId: string;
newMissionId: string;
rerollsRemaining: number;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useRerollMission() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
if (!user?.pubkey) throw new Error('Must be logged in');
const current = readMissionsFromStorage(user.pubkey);
if (!current) throw new Error('No missions state');
const updated = rerollMission(current, missionId, availableStages);
if (!updated) throw new Error('Cannot reroll this mission');
writeMissionsToStorage(updated, user.pubkey);
// Notify React
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, rerolled: true },
}));
// Find the new mission ID at the same index
const oldIdx = current.daily.findIndex((m) => m.id === missionId);
const newMissionId = updated.daily[oldIdx]?.id ?? missionId;
return {
oldMissionId: missionId,
newMissionId,
rerollsRemaining: updated.rerolls,
};
},
onSuccess: ({ newMissionId, rerollsRemaining }) => {
const def = getDefinition(newMissionId);
const rerollText = rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} reroll${rerollsRemaining === 1 ? '' : 's'} left`;
toast({
title: 'Mission Replaced',
description: `New mission: ${def?.title ?? newMissionId}. ${rerollText}.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to Reroll',
description: error.message,
variant: 'destructive',
});
},
});
}
+233
View File
@@ -0,0 +1,233 @@
// src/blobbi/actions/index.ts
// Components
export { BlobbiActionsModal } from './components/BlobbiActionsModal';
export { BlobbiActionInventoryModal } from './components/BlobbiActionInventoryModal';
export { PlayMusicModal } from './components/PlayMusicModal';
export { SingModal } from './components/SingModal';
export { InlineMusicPlayer } from './components/InlineMusicPlayer';
export { InlineSingCard } from './components/InlineSingCard';
export { HatchTasksPanel } from './components/HatchTasksPanel';
export { TasksPanel } from './components/TasksPanel';
export { BlobbiPostModal } from './components/BlobbiPostModal';
export { StartIncubationDialog } from './components/StartIncubationDialog';
export { StartEvolutionDialog } from './components/StartEvolutionDialog';
export { BlobbiMissionsModal } from './components/BlobbiMissionsModal';
// Hooks
export { useBlobbiUseInventoryItem } from './hooks/useBlobbiUseInventoryItem';
export type { UseItemRequest, UseItemResult, UseBlobbiUseInventoryItemParams } from './hooks/useBlobbiUseInventoryItem';
export { useBlobbiHatch, useBlobbiEvolve } from './hooks/useBlobbiStageTransition';
export type {
UseBlobbiStageTransitionParams,
StageTransitionResult,
CanonicalActionResult,
} from './hooks/useBlobbiStageTransition';
export {
useStartIncubation,
useStopIncubation,
useStartEvolution,
useStopEvolution,
useSyncTaskCompletions,
} from './hooks/useBlobbiIncubation';
export type {
StartIncubationMode,
StartIncubationRequest,
UseStartIncubationParams,
StartIncubationResult,
UseStopIncubationParams,
StopIncubationResult,
UseStartEvolutionParams,
StartEvolutionResult,
UseStopEvolutionParams,
StopEvolutionResult,
UseSyncTaskCompletionsParams,
TaskCompletionToSync,
} from './hooks/useBlobbiIncubation';
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from './hooks/useActiveTaskProcess';
export {
useHatchTasks,
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
REQUIRED_INTERACTIONS, // Legacy export
BLOBBI_POST_PREFIX,
BLOBBI_POST_REQUIRED_HASHTAGS,
} from './hooks/useHatchTasks';
export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTasks';
export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_STAT_THRESHOLD,
BLOBBI_EVOLVE_POST_PREFIX,
} from './hooks/useEvolveTasks';
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
export { useBlobbiDirectAction, DIRECT_ACTION_HAPPINESS_EFFECTS } from './hooks/useBlobbiDirectAction';
export type { DirectActionRequest, DirectActionResult, UseBlobbiDirectActionParams } from './hooks/useBlobbiDirectAction';
export { useAudioPlayback } from './hooks/useAudioPlayback';
export type { PlaybackState, PlaybackError, UseAudioPlaybackOptions, UseAudioPlaybackReturn } from './hooks/useAudioPlayback';
// Track catalog
export {
BLOBBI_TRACK_CATALOG,
getAllTracks,
getTrackById,
formatTrackDuration,
type BlobbiTrack,
} from './lib/blobbi-track-catalog';
// Activity state
export {
createMusicActivity,
createSingActivity,
createNoActivity,
type InlineActivityType,
type InlineActivityState,
type MusicActivityState,
type SingActivityState,
type NoActivityState,
type BlobbiReactionState,
type SelectedTrack,
} from './lib/blobbi-activity-state';
// Re-export stat bounds from canonical source
export { STAT_MIN, STAT_MAX } from '@/blobbi/core/lib/blobbi';
// Utilities
export {
// Types
type InventoryAction,
type DirectAction,
type BlobbiAction,
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
type IncrementInteractionResult,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
DIRECT_ACTION_METADATA,
ALL_ACTION_METADATA,
GENERAL_ITEM_USABLE_STAGES,
EGG_ALLOWED_ACTIONS,
EGG_ALLOWED_INVENTORY_ACTIONS,
EGG_ALLOWED_DIRECT_ACTIONS,
EGG_VISIBLE_INVENTORY_ACTIONS,
EGG_VISIBLE_ACTIONS,
SHELL_REPAIR_KIT_ID,
// Functions
clampStat,
applyStat,
applyItemEffects,
filterInventoryByAction,
decrementStorageItem,
canUseAction,
canUseDirectAction,
isActionVisibleForStage,
canUseInventoryItems,
getStageRestrictionMessage,
previewStatChanges,
previewMedicineForEgg,
previewCleanForEgg,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
canUseItemForStage,
getActionForItem,
incrementInteractionTaskTags,
} from './lib/blobbi-action-utils';
// Daily Missions
export { useDailyMissions } from './hooks/useDailyMissions';
export type { DailyMissionView, UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useAwardDailyXp, useClaimMissionReward } from './hooks/useClaimMissionReward';
export type { AwardDailyXpRequest, AwardDailyXpResult, ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export { useRerollMission } from './hooks/useRerollMission';
export type { RerollMissionRequest, RerollMissionResult } from './hooks/useRerollMission';
export {
trackDailyMissionProgress,
trackDailyMissionEvent,
trackMultipleDailyMissionActions,
} from './lib/daily-mission-tracker';
export type {
DailyMissionAction,
DailyMissionDefinition,
Mission,
TallyMission,
EventMission,
MissionsContent,
} from './lib/daily-missions';
// Progression
export {
xpToLevel,
levelToXp,
xpProgress,
xpToNextLevel,
getUnlocks,
buildXpTagUpdates,
MAX_LEVEL,
} from '@/blobbi/core/lib/progression';
export type { Unlocks } from '@/blobbi/core/lib/progression';
// Missions content model
export {
parseProfileContent,
serializeProfileContent,
isMissionComplete,
isTallyMission,
isEventMission,
missionProgress,
} from '@/blobbi/core/lib/missions';
export type { ProfileContent } from '@/blobbi/core/lib/missions';
// Item cooldown
export { isItemOnCooldown, setItemCooldown, subscribeCooldowns } from './lib/item-cooldown';
export { ITEM_COOLDOWN_SUCCESS_MS, ITEM_COOLDOWN_FAILURE_MS } from './lib/item-cooldown';
export { useItemCooldown } from './hooks/useItemCooldown';
// Action XP
export {
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
POOP_CLEANUP_XP,
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
formatXPGain,
} from './lib/blobbi-xp';
// Streak tracking
export {
calculateStreakUpdate,
getStreakTagUpdates,
needsStreakUpdate,
getStreakStatus,
} from './lib/blobbi-streak';
export type {
StreakUpdateResult,
StreakTagUpdates,
} from './lib/blobbi-streak';
export { useBlobbiCareActivity } from './hooks/useBlobbiCareActivity';
export type {
UseBlobbiCareActivityParams,
CareActivityResult,
} from './hooks/useBlobbiCareActivity';
@@ -0,0 +1,632 @@
// src/blobbi/actions/lib/blobbi-action-utils.ts
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, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Item-based care actions (use a shop catalog item on the companion)
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (item-based + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
/**
* Mapping from action type to allowed item categories
*/
export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
feed: 'food',
play: 'toy',
clean: 'hygiene',
medicine: 'medicine',
};
/**
* Action metadata for UI display (item-based care actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
label: 'Feed',
description: 'Feed your Blobbi',
icon: '🍎',
},
play: {
label: 'Play',
description: 'Play with your Blobbi',
icon: '⚽',
},
clean: {
label: 'Clean',
description: 'Clean your Blobbi',
icon: '🧼',
},
medicine: {
label: 'Medicine',
description: 'Heal your Blobbi',
icon: '💊',
},
};
/**
* Action metadata for direct actions (no item required)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
label: 'Play Music',
description: 'Play music for your Blobbi',
icon: '🎵',
},
sing: {
label: 'Sing',
description: 'Sing to your Blobbi',
icon: '🎤',
},
};
/**
* Combined action metadata for all action types
*/
export const ALL_ACTION_METADATA: Record<BlobbiAction, { label: string; description: string; icon: string }> = {
...ACTION_METADATA,
...DIRECT_ACTION_METADATA,
};
// ─── Stat Helpers ─────────────────────────────────────────────────────────────
// STAT_MIN and STAT_MAX are imported from @/lib/blobbi (single source of truth)
/**
* Clamp a stat value between STAT_MIN (1) and STAT_MAX (100).
* Safe for undefined values (returns STAT_MIN).
*
* The minimum of 1 (instead of 0) ensures:
* - Blobbi is never in an unrecoverable state
* - Visual feedback shows critical state without being "dead"
* - Recovery is always possible with any healing item
*/
export function clampStat(value: number | undefined): number {
if (value === undefined) return STAT_MIN;
return Math.max(STAT_MIN, Math.min(STAT_MAX, Math.round(value)));
}
/**
* Apply a delta to a stat, clamping the result to STAT_MIN-STAT_MAX.
*/
export function applyStat(current: number | undefined, delta: number): number {
const currentValue = current ?? STAT_MIN;
return clampStat(currentValue + delta);
}
/**
* Apply item effects to current stats.
* Returns a new partial stats object with all affected stats clamped.
* Only modifies stats that have corresponding effects.
*/
export function applyItemEffects(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect
): Partial<BlobbiStats> {
const newStats: Partial<BlobbiStats> = { ...currentStats };
if (effects.hunger !== undefined) {
newStats.hunger = applyStat(currentStats.hunger, effects.hunger);
}
if (effects.happiness !== undefined) {
newStats.happiness = applyStat(currentStats.happiness, effects.happiness);
}
if (effects.energy !== undefined) {
newStats.energy = applyStat(currentStats.energy, effects.energy);
}
if (effects.hygiene !== undefined) {
newStats.hygiene = applyStat(currentStats.hygiene, effects.hygiene);
}
if (effects.health !== undefined) {
newStats.health = applyStat(currentStats.health, effects.health);
}
return newStats;
}
// ─── Egg-Specific Item Helpers ────────────────────────────────────────────────
/**
* The Shell Repair Kit is a special medicine item only usable by eggs.
*/
export const SHELL_REPAIR_KIT_ID = 'med_shell_repair';
/**
* Result of checking if an item can be used by a specific Blobbi stage.
*/
export interface ItemUsabilityResult {
canUse: boolean;
reason?: string;
}
/**
* Check if a specific item can be used by a companion at the given stage.
*
* This is the centralized item usability logic:
* - Shell Repair Kit: Only usable by eggs
* - Food items: Only usable by baby/adult (not eggs)
* - Toy items: Only usable by baby/adult (not eggs)
* - Medicine items (except Shell Repair Kit): Usable by all stages with health effect
* - Hygiene items: Usable by all stages
*
* @param itemId - The shop item ID
* @param stage - The companion's life stage
* @returns Object with canUse boolean and optional reason string
*/
export function canUseItemForStage(
itemId: string,
stage: 'egg' | 'baby' | 'adult'
): ItemUsabilityResult {
const shopItem = getShopItemById(itemId);
if (!shopItem) {
return { canUse: false, reason: 'Item not found' };
}
const isEgg = stage === 'egg';
// Shell Repair Kit special case: only for eggs
if (itemId === SHELL_REPAIR_KIT_ID) {
if (!isEgg) {
return { canUse: false, reason: 'Only usable for eggs' };
}
return { canUse: true };
}
// Food items: not usable by eggs
if (shopItem.type === 'food') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot eat food' };
}
return { canUse: true };
}
// Toy items: not usable by eggs
if (shopItem.type === 'toy') {
if (isEgg) {
return { canUse: false, reason: 'Eggs cannot use toys' };
}
return { canUse: true };
}
// Medicine items (except Shell Repair Kit): check for health effect
if (shopItem.type === 'medicine') {
if (!hasMedicineEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This medicine has no effect' };
}
return { canUse: true };
}
// Hygiene items: all stages can use
if (shopItem.type === 'hygiene') {
if (!hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
return { canUse: false, reason: 'This item has no cleaning effect' };
}
return { canUse: true };
}
return { canUse: true };
}
/**
* Get the action type for a given item.
*/
export function getActionForItem(itemId: string): InventoryAction | null {
const shopItem = getShopItemById(itemId);
if (!shopItem) return null;
const typeToAction: Record<string, InventoryAction> = {
food: 'feed',
toy: 'play',
hygiene: 'clean',
medicine: 'medicine',
};
return typeToAction[shopItem.type] ?? null;
}
/**
* Check if a medicine item has any effect on an egg.
*
* Eggs use the standard 3-stat model:
* - health
* - hygiene
* - happiness
*
* Medicine with a health effect will directly affect the egg's health stat.
*/
export function hasMedicineEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.health !== undefined && effects.health !== 0;
}
/**
* Check if a hygiene item has any effect on an egg.
* Hygiene items with a hygiene effect will directly affect the egg's hygiene stat.
*/
export function hasHygieneEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.hygiene !== undefined && effects.hygiene !== 0;
}
/**
* Check if an item has a happiness effect for an egg.
* Some items (like bubble bath) give happiness bonus in addition to primary effects.
*/
export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boolean {
if (!effects) return false;
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved catalog item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
quantity: number;
name: string;
icon: string;
type: ShopItemCategory;
effect?: ItemEffect;
}
/**
* Options for filtering catalog items by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
stage?: 'egg' | 'baby' | 'adult';
}
/**
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
* - Shell Repair Kit only appears in medicine modal for eggs
* - For eggs: only items with egg-compatible effects are returned
* - medicine action: only items with health effect
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const shopItem of allItems) {
if (shopItem.type !== allowedType) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
// For eggs, filter items by egg-compatible effects
if (isEgg) {
if (action === 'medicine' && !hasMedicineEffectForEgg(shopItem.effect)) {
continue; // Skip medicine without health effect
}
if (action === 'clean' && !hasHygieneEffectForEgg(shopItem.effect) && !hasHappinessEffectForEgg(shopItem.effect)) {
continue; // Skip hygiene items without hygiene or happiness effect
}
}
result.push({
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
effect: shopItem.effect,
});
}
return result;
}
/**
* Decrement item quantity in storage array.
* If quantity becomes 0, removes the item entirely.
* Returns a new storage array (immutable).
*/
export function decrementStorageItem(
storage: StorageItem[],
itemId: string,
amount = 1
): StorageItem[] {
const result: StorageItem[] = [];
for (const item of storage) {
if (item.itemId !== itemId) {
result.push(item);
continue;
}
const newQuantity = item.quantity - amount;
if (newQuantity > 0) {
result.push({ ...item, quantity: newQuantity });
}
// If newQuantity <= 0, we don't add it (remove item)
}
return result;
}
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
/**
* Inventory actions that are allowed for eggs.
* Eggs can use: medicine (health), clean (hygiene)
*/
export const EGG_ALLOWED_INVENTORY_ACTIONS: InventoryAction[] = ['medicine', 'clean'];
/**
* Direct actions that are allowed for eggs.
* All direct actions work on eggs.
*/
export const EGG_ALLOWED_DIRECT_ACTIONS: DirectAction[] = ['play_music', 'sing'];
/**
* Inventory actions visible in the egg UI.
* Note: feed, play, sleep are hidden in the UI for eggs but not hard-blocked.
*/
export const EGG_VISIBLE_INVENTORY_ACTIONS: InventoryAction[] = ['clean', 'medicine'];
/**
* All actions visible in the egg UI.
*/
export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_music', 'sing'];
/**
* @deprecated Use EGG_ALLOWED_INVENTORY_ACTIONS instead
*/
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific item action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all item actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
/**
* Check if a companion can use a specific direct action.
* Direct actions (play_music, sing) are available for all stages.
*/
export function canUseDirectAction(_companion: BlobbiCompanion, _action: DirectAction): boolean {
// All stages can use direct actions
return true;
}
/**
* Check if an action should be visible in the UI for a given stage.
* This is for UI filtering only - some actions are hidden but not blocked.
*/
export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action: BlobbiAction): boolean {
if (stage === 'egg') {
return EGG_VISIBLE_ACTIONS.includes(action);
}
return true; // baby and adult see all actions
}
/**
* Check if a companion can use general items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
export function canUseInventoryItems(companion: BlobbiCompanion): boolean {
return GENERAL_ITEM_USABLE_STAGES.includes(companion.stage as typeof GENERAL_ITEM_USABLE_STAGES[number]);
}
/**
* Get a user-friendly message explaining why an action can't be used.
*/
export function getStageRestrictionMessage(companion: BlobbiCompanion, action?: InventoryAction): string | null {
if (companion.stage === 'egg') {
if (action && EGG_ALLOWED_INVENTORY_ACTIONS.includes(action)) {
return null; // Medicine and clean are allowed for eggs
}
return 'Eggs cannot use this item. Wait for your Blobbi to hatch!';
}
return null;
}
// ─── Stats Preview ────────────────────────────────────────────────────────────
/**
* Preview stats after applying an item's effects.
* Useful for showing the user what will happen before confirming.
*/
export function previewStatChanges(
currentStats: Partial<BlobbiStats>,
effects: ItemEffect | undefined
): Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> {
if (!effects) return [];
const changes: Array<{ stat: keyof BlobbiStats; current: number; after: number; delta: number }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'energy', 'hygiene', 'health'];
for (const stat of statKeys) {
const delta = effects[stat];
if (delta !== undefined && delta !== 0) {
const current = currentStats[stat] ?? 0;
const after = clampStat(current + delta);
changes.push({ stat, current, after, delta });
}
}
return changes;
}
/**
* Preview stat change for an egg.
* Eggs use the 3-stat model: health, hygiene, happiness.
*/
export type EggStatPreview = { stat: 'health' | 'hygiene' | 'happiness'; current: number; after: number; delta: number };
/**
* Preview medicine effects for an egg.
* Medicine directly affects the egg's health stat.
*/
export function previewMedicineForEgg(
currentHealth: number | undefined,
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects || effects.health === undefined || effects.health === 0) {
return [];
}
const current = currentHealth ?? 100;
const delta = effects.health;
const after = clampStat(current + delta);
return [{ stat: 'health', current, after, delta }];
}
/**
* Preview clean (hygiene) effects for an egg.
* Hygiene items directly affect the egg's hygiene stat.
* May also include happiness bonus if the item has one.
*/
export function previewCleanForEgg(
currentStats: { hygiene?: number; happiness?: number },
effects: ItemEffect | undefined
): EggStatPreview[] {
if (!effects) return [];
const results: EggStatPreview[] = [];
// Hygiene effect
if (effects.hygiene !== undefined && effects.hygiene !== 0) {
const current = currentStats.hygiene ?? 100;
const delta = effects.hygiene;
const after = clampStat(current + delta);
results.push({ stat: 'hygiene', current, after, delta });
}
// Happiness bonus (some hygiene items like bubble bath give happiness)
if (effects.happiness !== undefined && effects.happiness !== 0) {
const current = currentStats.happiness ?? 100;
const delta = effects.happiness;
const after = clampStat(current + delta);
results.push({ stat: 'happiness', current, after, delta });
}
return results;
}
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
/**
* Result of incrementing interaction task tags
*/
export interface IncrementInteractionResult {
/** Updated tags array */
updatedTags: string[][];
/** New interaction count after increment */
newCount: number;
/** Whether the task is now complete */
isCompleted: boolean;
/** Previous count before increment */
previousCount: number;
}
/**
* Increment the interaction task counter in the tags array.
*
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
* to track progress on interaction tasks for both hatch and evolve.
*
* CRITICAL: This function is called during actual user actions (not retroactive sync).
* It always increments by 1 because each call represents a real interaction.
*
* Tag format:
* - Progress: ["task", "interactions:N"]
* - Completion: ["task_completed", "interactions"]
*
* Idempotency notes:
* - This is NOT idempotent by design - each call = one interaction
* - Duplicate task_completed tags are prevented by filtering before add
* - Multiple task:interactions tags are prevented by filtering before add
*
* @param currentTags - Current tags array from the Blobbi state
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
* @returns Updated tags array with incremented interaction count
*/
export function incrementInteractionTaskTags(
currentTags: string[][],
requiredInteractions: number
): IncrementInteractionResult {
// Get current interaction count from task tags
const interactionTag = currentTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const previousCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = previousCount + 1;
// Check if already completed (task_completed tag exists)
const alreadyCompleted = currentTags.some(tag =>
tag[0] === 'task_completed' && tag[1] === 'interactions'
);
// Remove old interaction task tag (prevent duplicates) and add new one
let updatedTags = currentTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached required count AND not already marked
const isCompleted = newCount >= requiredInteractions;
if (isCompleted && !alreadyCompleted) {
// Only add if not already present (handled by filter, but double-check)
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
if (DEBUG_INTERACTION_TASK) {
console.log('[InteractionTask] Increment:', {
previousCount,
newCount,
requiredInteractions,
isCompleted,
alreadyCompleted,
addedCompletionTag: isCompleted && !alreadyCompleted,
});
}
return { updatedTags, newCount, isCompleted, previousCount };
}
@@ -0,0 +1,81 @@
// src/blobbi/actions/lib/blobbi-activity-state.ts
import type { SelectedTrack } from '../components/PlayMusicModal';
/**
* Types of inline activities that can be displayed in BlobbiPage
*/
export type InlineActivityType = 'none' | 'music' | 'sing';
// Re-export for convenience
export type { SelectedTrack } from '../components/PlayMusicModal';
/**
* State for the music inline activity
*/
export interface MusicActivityState {
type: 'music';
selection: SelectedTrack;
isPublished: boolean;
}
/**
* State for the sing inline activity
*/
export interface SingActivityState {
type: 'sing';
}
/**
* No active inline activity
*/
export interface NoActivityState {
type: 'none';
}
/**
* Union type for all inline activity states
*/
export type InlineActivityState =
| NoActivityState
| MusicActivityState
| SingActivityState;
/**
* Blobbi reaction state - indicates how Blobbi should visually react
*/
export type BlobbiReactionState =
| 'idle' // No special reaction
| 'listening' // Music is playing, Blobbi is listening
| 'swaying' // Blobbi is swaying to music
| 'singing' // User is singing, Blobbi is engaged
| 'happy'; // General happy reaction
/**
* Helper to create a music activity state
*/
export function createMusicActivity(selection: SelectedTrack): MusicActivityState {
return {
type: 'music',
selection,
isPublished: false,
};
}
/**
* Helper to create a sing activity state
*/
export function createSingActivity(): SingActivityState {
return {
type: 'sing',
};
}
/**
* Helper to create no activity state
*/
export function createNoActivity(): NoActivityState {
return {
type: 'none',
};
}
@@ -0,0 +1,121 @@
// src/blobbi/actions/lib/blobbi-random-lyrics.ts
/**
* Random lyrics for the Sing action.
* These are fun, simple lyrics that users can sing to their Blobbi.
*/
export interface LyricsEntry {
id: string;
title: string;
lines: string[];
}
/**
* Collection of placeholder lyrics for singing to a Blobbi.
* Simple, fun, and appropriate for all ages.
*/
export const BLOBBI_LYRICS: LyricsEntry[] = [
{
id: 'lullaby-1',
title: 'Blobbi Lullaby',
lines: [
'Little Blobbi, close your eyes,',
'Dream of stars up in the skies.',
'Safe and warm, you drift away,',
"We'll play again another day.",
],
},
{
id: 'happy-song-1',
title: 'Happy Blobbi Song',
lines: [
'Blobbi, Blobbi, jump around!',
"You're the happiest friend I've found!",
'Dancing, playing, full of cheer,',
"I'm so glad that you are here!",
],
},
{
id: 'adventure-1',
title: 'Adventure Time',
lines: [
"Let's go on an adventure today,",
'Through the clouds and far away!',
'Mountains high and valleys deep,',
'Memories to always keep.',
],
},
{
id: 'breakfast-song',
title: 'Breakfast Song',
lines: [
'Wake up, wake up, sleepy head,',
"Time to get out of your bed!",
"Breakfast's waiting, fresh and yummy,",
'Food to fill your happy tummy!',
],
},
{
id: 'rainy-day',
title: 'Rainy Day',
lines: [
'Pitter patter on the roof,',
'Rainy days can be so nice.',
"We'll stay cozy, me and you,",
'Watching raindrops, one by two.',
],
},
{
id: 'sunshine-song',
title: 'Sunshine Song',
lines: [
'Good morning, sunshine, bright and warm,',
'A brand new day is being born!',
'Blue sky smiling down on me,',
'Happy as can be, so free!',
],
},
{
id: 'bedtime-1',
title: 'Bedtime Blues',
lines: [
'The moon is up, the stars are bright,',
'Time to say a soft goodnight.',
'Snuggle up and close your eyes,',
'Sweet dreams under starry skies.',
],
},
{
id: 'play-time',
title: 'Play Time',
lines: [
"Bounce and jump and run around,",
"Spin and twirl without a sound!",
"Playing games is so much fun,",
"Laughing underneath the sun!",
],
},
];
/**
* Get a random lyrics entry.
*/
export function getRandomLyrics(): LyricsEntry {
const index = Math.floor(Math.random() * BLOBBI_LYRICS.length);
return BLOBBI_LYRICS[index];
}
/**
* Get all available lyrics entries.
*/
export function getAllLyrics(): LyricsEntry[] {
return BLOBBI_LYRICS;
}
/**
* Format lyrics for display (joined with newlines).
*/
export function formatLyrics(lyrics: LyricsEntry): string {
return lyrics.lines.join('\n');
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Blobbi Care Streak Management
*
* This module provides centralized logic for tracking care streaks on Blobbi companions.
* A streak represents consecutive days of care activity (opening Blobbi page, performing
* care actions, etc.).
*
* Streak Rules:
* - Starts at 1 on first activity
* - Increments when activity happens on the NEXT local calendar day
* - Same-day activity does not increment (at most once per day)
* - Missing 2+ days resets streak to 1
*
* Tags managed:
* - care_streak: The current streak count (positive integer)
* - care_streak_last_at: Unix timestamp (seconds) of last streak update
* - care_streak_last_day: Local calendar day string (YYYY-MM-DD) of last update
*/
import {
getLocalDayString,
getDaysDifference,
type BlobbiCompanion,
} from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of calculating a streak update.
*/
export interface StreakUpdateResult {
/** Whether the streak was updated (incremented or reset) */
wasUpdated: boolean;
/** The new streak value */
newStreak: number;
/** The new timestamp for care_streak_last_at */
newLastAt: number;
/** The new day string for care_streak_last_day */
newLastDay: string;
/** Description of what happened (for debugging/logging) */
action: 'initialized' | 'incremented' | 'reset' | 'same_day';
}
/**
* Tag updates to apply to the Blobbi event.
* Only present if wasUpdated is true.
* Uses index signature for compatibility with updateBlobbiTags.
*/
export interface StreakTagUpdates {
care_streak: string;
care_streak_last_at: string;
care_streak_last_day: string;
[key: string]: string;
}
// ─── Core Logic ───────────────────────────────────────────────────────────────
/**
* Calculate what the streak should be updated to based on current state and activity.
*
* This is a pure function that calculates the new streak state without side effects.
* Use this to determine if/how the streak should be updated.
*
* @param currentStreak - Current streak value (0 or undefined means no streak yet)
* @param lastDay - The last day string (YYYY-MM-DD) when streak was updated, or undefined
* @param now - Current timestamp (defaults to now)
* @returns StreakUpdateResult describing the update
*/
export function calculateStreakUpdate(
currentStreak: number | undefined,
lastDay: string | undefined,
now: Date = new Date()
): StreakUpdateResult {
const nowTimestamp = Math.floor(now.getTime() / 1000);
const todayString = getLocalDayString(now);
// Case 1: No existing streak - initialize to 1
if (currentStreak === undefined || currentStreak === 0 || !lastDay) {
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'initialized',
};
}
// Case 2: Activity on the same day - no update needed
if (lastDay === todayString) {
return {
wasUpdated: false,
newStreak: currentStreak,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'same_day',
};
}
// Calculate days since last activity
const daysMissed = getDaysDifference(lastDay, todayString);
// Case 3: Next day (1 day difference) - increment streak
if (daysMissed === 1) {
return {
wasUpdated: true,
newStreak: currentStreak + 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'incremented',
};
}
// Case 4: Missed 2+ days - reset to 1
return {
wasUpdated: true,
newStreak: 1,
newLastAt: nowTimestamp,
newLastDay: todayString,
action: 'reset',
};
}
/**
* Get the tag updates to apply to a Blobbi event for a streak update.
* Returns undefined if no update is needed (same day activity).
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns Tag updates to apply, or undefined if no update needed
*/
export function getStreakTagUpdates(
companion: BlobbiCompanion,
now: Date = new Date()
): StreakTagUpdates | undefined {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
if (!result.wasUpdated) {
return undefined;
}
return {
care_streak: result.newStreak.toString(),
care_streak_last_at: result.newLastAt.toString(),
care_streak_last_day: result.newLastDay,
};
}
/**
* Check if a streak update is needed for the companion.
*
* @param companion - The current Blobbi companion state
* @param now - Current timestamp (defaults to now)
* @returns true if the streak should be updated
*/
export function needsStreakUpdate(
companion: BlobbiCompanion,
now: Date = new Date()
): boolean {
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
now
);
return result.wasUpdated;
}
/**
* Get the current streak status for display purposes.
*
* @param companion - The current Blobbi companion state
* @returns Object with streak info for UI display
*/
export function getStreakStatus(companion: BlobbiCompanion): {
streak: number;
lastDay: string | undefined;
isActive: boolean;
daysSinceLastActivity: number | undefined;
} {
const streak = companion.careStreak ?? 0;
const lastDay = companion.careStreakLastDay;
const today = getLocalDayString();
let daysSinceLastActivity: number | undefined;
let isActive = false;
if (lastDay) {
daysSinceLastActivity = getDaysDifference(lastDay, today);
// Streak is "active" if we've had activity today or yesterday
isActive = daysSinceLastActivity <= 1;
}
return {
streak,
lastDay,
isActive,
daysSinceLastActivity,
};
}
@@ -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);
}
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* 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
* - Item actions (feed, play, clean, medicine) give varied XP per action type
* - Direct actions (sing, play_music) give moderate XP
* - XP accumulates across all life stages and never resets
*/
import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-action-utils';
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for item-based care actions (feed, play, clean, medicine).
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is critical - highest item XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't require selecting an item.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
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 awarded for cleaning up poop.
*/
export const POOP_CLEANUP_XP = 5;
// ─── 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 XP gain for an item-based care action.
*
* @param action - The action performed
* @param quantity - Number of times performed (always 1 in current usage)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
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 (always 1 in current usage)
* @returns Object with xpGained and 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!`;
}
@@ -0,0 +1,115 @@
/**
* Daily Mission Tracker - Standalone progress tracking utility
*
* Provides a way to record daily mission progress from anywhere
* (hooks, event handlers, etc.) without requiring React context.
*
* Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the
* persistent source of truth. Completed missions are persisted by
* `useAwardDailyXp`; intermediate progress resets on page refresh.
*
* Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render.
*/
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import type { DailyMissionAction } from './daily-missions';
import {
getTodayDateString,
needsDailyReset,
createDailyMissionsContent,
trackTally,
trackEvent,
} from './daily-missions';
// ─── In-Memory Session Store ──────────────────────────────────────────────────
/**
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
* Cleared on page refresh (intentional — kind 11125 is the persistent store).
*/
const sessionStore = new Map<string, MissionsContent>();
function key(pubkey: string | undefined): string {
return pubkey ?? '';
}
function ensureCurrent(pubkey?: string): MissionsContent {
const current = sessionStore.get(key(pubkey));
if (!needsDailyReset(current)) return current!;
const fresh = createDailyMissionsContent(
getTodayDateString(),
current?.evolution ?? [],
pubkey,
);
sessionStore.set(key(pubkey), fresh);
return fresh;
}
function notify(detail?: Record<string, unknown>): void {
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Record a tally-based action (feed, clean, interact, etc.).
*/
export function trackDailyMissionProgress(
action: DailyMissionAction,
count: number = 1,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const updated = trackTally(current, action, count);
sessionStore.set(key(pubkey), updated);
notify({ action, count });
}
/**
* Record an event-based action (take_photo, etc.) with its Nostr event ID.
*/
export function trackDailyMissionEvent(
action: DailyMissionAction,
eventId: string,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const updated = trackEvent(current, action, eventId);
sessionStore.set(key(pubkey), updated);
notify({ action, eventId });
}
/**
* Track multiple tally actions at once.
*/
export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string,
): void {
let current = ensureCurrent(pubkey);
for (const action of actions) {
current = trackTally(current, action, 1);
}
sessionStore.set(key(pubkey), current);
notify({ actions });
}
/** Read current session state for a pubkey. */
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
return sessionStore.get(key(pubkey));
}
/** Write state to session store for a pubkey. */
export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void {
sessionStore.set(key(pubkey), missions);
}
/**
* Hydrate the session store from kind 11125 persisted data.
* Called once on mount / account switch when the session store is empty.
* No-op if the store already has data for this pubkey.
*/
export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void {
if (sessionStore.has(pubkey)) return;
sessionStore.set(pubkey, missions);
}
+453
View File
@@ -0,0 +1,453 @@
/**
* Daily Missions System for Blobbi
*
* Defines the daily mission pool, selection logic, and state management.
* Missions use the tally/event model from missions.ts:
* - Tally missions: { id, target, count }
* - Event missions: { id, target, events }
* Completion is derived: count >= target or events.length >= target.
* No explicit completed/claimed flags.
*/
import type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
import { isTallyMission, isEventMission, isMissionComplete } from '@/blobbi/core/lib/missions';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Actions that can trigger daily mission progress.
* Tally actions increment a counter. Event actions append an event ID.
*/
export type DailyMissionAction =
| 'interact' // Any care interaction (tally)
| 'feed' // Feeding action (tally)
| 'clean' // Cleaning action (tally)
| 'sing' // Sing direct action (tally)
| 'play_music' // Play music direct action (tally)
| 'sleep' // Put Blobbi to sleep (tally)
| 'take_photo' // Take a photo (event)
| 'medicine'; // Give medicine (tally)
/** Whether a mission action tracks events or tallies */
export type MissionTrackingType = 'tally' | 'event';
/** Blobbi stage type for filtering missions */
export type BlobbiStage = 'egg' | 'baby' | 'adult';
/**
* Definition of a daily mission in the pool.
* This is the static template -- not the runtime state.
*/
export interface DailyMissionDefinition {
/** Unique identifier */
id: string;
/** Display title */
title: string;
/** Description of what to do */
description: string;
/** Action that triggers progress */
action: DailyMissionAction;
/** Number of times the action must be performed */
target: number;
/** Whether this mission tracks events or tallies */
tracking: MissionTrackingType;
/** XP reward for completing this mission */
xp: number;
/** Selection weight (higher = more likely) */
weight: number;
/** Required stages to show this mission */
requiredStages?: BlobbiStage[];
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Maximum number of mission rerolls allowed per day */
export const MAX_DAILY_REROLLS = 3;
/** Number of daily missions selected each day */
export const DAILY_MISSION_COUNT = 3;
/** XP bonus for completing all daily missions */
export const DAILY_BONUS_XP = 50;
// ─── Mission Pool ─────────────────────────────────────────────────────────────
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
// ── Baby/Adult only ──────────────────────────────────────────────────────
{
id: 'interact_3', title: 'Quick Care',
description: 'Interact with your Blobbi 3 times',
action: 'interact', target: 3, tracking: 'tally', xp: 15, weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'interact_6', title: 'Attentive Caretaker',
description: 'Interact with your Blobbi 6 times',
action: 'interact', target: 6, tracking: 'tally', xp: 30, weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_1', title: 'Snack Time',
description: 'Feed your Blobbi once',
action: 'feed', target: 1, tracking: 'tally', xp: 10, weight: 10,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_2', title: 'Hungry Blobbi',
description: 'Feed your Blobbi 2 times',
action: 'feed', target: 2, tracking: 'tally', xp: 20, weight: 8,
requiredStages: ['baby', 'adult'],
},
{
id: 'feed_3', title: 'Feast Day',
description: 'Feed your Blobbi 3 times',
action: 'feed', target: 3, tracking: 'tally', xp: 35, weight: 5,
requiredStages: ['baby', 'adult'],
},
{
id: 'sleep_1', title: 'Nap Time',
description: 'Put your Blobbi to sleep',
action: 'sleep', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_1', title: 'Snapshot',
description: 'Take a photo of your Blobbi',
action: 'take_photo', target: 1, tracking: 'event', xp: 25, weight: 4,
requiredStages: ['baby', 'adult'],
},
{
id: 'take_photo_2', title: 'Photo Album',
description: 'Take 2 photos of your Blobbi',
action: 'take_photo', target: 2, tracking: 'event', xp: 40, weight: 2,
requiredStages: ['baby', 'adult'],
},
// ── All stages ───────────────────────────────────────────────────────────
{
id: 'clean_1', title: 'Quick Cleanup',
description: 'Clean your Blobbi once',
action: 'clean', target: 1, tracking: 'tally', xp: 10, weight: 10,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'clean_2', title: 'Squeaky Clean',
description: 'Clean your Blobbi 2 times',
action: 'clean', target: 2, tracking: 'tally', xp: 20, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_1', title: 'Sing Along',
description: 'Sing a song to your Blobbi',
action: 'sing', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'sing_2', title: 'Karaoke Session',
description: 'Sing 2 songs to your Blobbi',
action: 'sing', target: 2, tracking: 'tally', xp: 25, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_1', title: 'DJ Time',
description: 'Play a song for your Blobbi',
action: 'play_music', target: 1, tracking: 'tally', xp: 15, weight: 6,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'play_music_2', title: 'Music Marathon',
description: 'Play 2 songs for your Blobbi',
action: 'play_music', target: 2, tracking: 'tally', xp: 25, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_1', title: 'Health Check',
description: 'Give medicine to your Blobbi',
action: 'medicine', target: 1, tracking: 'tally', xp: 20, weight: 5,
requiredStages: ['egg', 'baby', 'adult'],
},
{
id: 'medicine_2', title: 'Doctor Visit',
description: 'Give medicine to your Blobbi 2 times',
action: 'medicine', target: 2, tracking: 'tally', xp: 35, weight: 3,
requiredStages: ['egg', 'baby', 'adult'],
},
];
// ─── Lookup ──────────────────────────────────────────────────────────────────
const POOL_BY_ID = new Map(DAILY_MISSION_POOL.map((d) => [d.id, d]));
/** Look up a mission definition by ID */
export function getDefinition(id: string): DailyMissionDefinition | undefined {
return POOL_BY_ID.get(id);
}
// ─── Date Utilities ──────────────────────────────────────────────────────────
/** YYYY-MM-DD in local timezone */
export function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
/** Whether the missions content needs a daily reset */
export function needsDailyReset(missions: MissionsContent | undefined): boolean {
if (!missions) return true;
return missions.date !== getTodayDateString();
}
// ─── Selection ───────────────────────────────────────────────────────────────
/** Seeded PRNG (Mulberry32) */
function seededRandom(seed: number): () => number {
return function () {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function generateDailySeed(dateString: string, pubkey?: string): number {
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash);
}
function isMissionAvailableForStages(def: DailyMissionDefinition, stages: BlobbiStage[]): boolean {
const required = def.requiredStages ?? ['baby', 'adult'];
return required.some((s) => stages.includes(s));
}
/**
* Select N missions deterministically from the pool.
* Seeded by date + pubkey so the same user gets the same missions for a given day.
*/
export function selectDailyMissions(
count: number,
dateString: string,
pubkey?: string,
availableStages?: BlobbiStage[],
): DailyMissionDefinition[] {
const stages = availableStages ?? ['baby', 'adult'];
const eligible = DAILY_MISSION_POOL.filter((m) => isMissionAvailableForStages(m, stages));
if (eligible.length === 0) return [];
const random = seededRandom(generateDailySeed(dateString, pubkey));
const available = [...eligible];
const selected: DailyMissionDefinition[] = [];
while (selected.length < count && available.length > 0) {
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
let pick = random() * totalWeight;
let idx = 0;
for (let i = 0; i < available.length; i++) {
pick -= available[i].weight;
if (pick <= 0) { idx = i; break; }
}
selected.push(available[idx]);
available.splice(idx, 1);
}
return selected;
}
// ─── Mission Instantiation ───────────────────────────────────────────────────
/** Create a fresh Mission from a definition */
export function createMission(def: DailyMissionDefinition): Mission {
if (def.tracking === 'event') {
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
}
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
}
/** Create a fresh MissionsContent for a new day, preserving evolution missions */
export function createDailyMissionsContent(
dateString: string,
existingEvolution: Mission[],
pubkey?: string,
availableStages?: BlobbiStage[],
): MissionsContent {
const defs = selectDailyMissions(DAILY_MISSION_COUNT, dateString, pubkey, availableStages);
return {
date: dateString,
daily: defs.map(createMission),
evolution: existingEvolution,
rerolls: MAX_DAILY_REROLLS,
};
}
// ─── Progress Tracking ───────────────────────────────────────────────────────
/**
* Increment tally for all daily missions matching the given action.
* Returns a new missions content (immutable).
*/
export function trackTally(
missions: MissionsContent,
action: DailyMissionAction,
incrementBy: number = 1,
): MissionsContent {
const updated = missions.daily.map((m) => {
const def = POOL_BY_ID.get(m.id);
if (!def || def.action !== action) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m; // already complete
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
});
return { ...missions, daily: updated };
}
/**
* Append an event ID to a daily mission.
* Deduplicates by event ID. Returns new missions content.
*/
export function trackEvent(
missions: MissionsContent,
action: DailyMissionAction,
eventId: string,
): MissionsContent {
const updated = missions.daily.map((m) => {
const def = POOL_BY_ID.get(m.id);
if (!def || def.action !== action) return m;
if (!isEventMission(m)) return m;
if (m.events.length >= m.target) return m; // already complete
if (m.events.includes(eventId)) return m; // dedup
return { ...m, events: [...m.events, eventId] };
});
return { ...missions, daily: updated };
}
/**
* Track progress for an evolution mission by tally.
*/
export function trackEvolutionTally(
missions: MissionsContent,
missionId: string,
incrementBy: number = 1,
): MissionsContent {
const updated = missions.evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isTallyMission(m)) return m;
if (m.count >= m.target) return m;
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
});
return { ...missions, evolution: updated };
}
/**
* Append an event ID to an evolution mission.
*/
export function trackEvolutionEvent(
missions: MissionsContent,
missionId: string,
eventId: string,
): MissionsContent {
const updated = missions.evolution.map((m) => {
if (m.id !== missionId) return m;
if (!isEventMission(m)) return m;
if (m.events.length >= m.target) return m;
if (m.events.includes(eventId)) return m;
return { ...m, events: [...m.events, eventId] };
});
return { ...missions, evolution: updated };
}
// ─── Completion Queries ──────────────────────────────────────────────────────
/** Whether all daily missions are complete */
export function areAllDailyComplete(missions: MissionsContent): boolean {
return missions.daily.length > 0 && missions.daily.every(isMissionComplete);
}
/** Whether all evolution missions are complete */
export function areAllEvolutionComplete(missions: MissionsContent): boolean {
return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete);
}
/** Total XP available from today's daily missions (including bonus if all complete) */
export function totalDailyXp(missions: MissionsContent): number {
const base = missions.daily.reduce((sum, m) => {
const def = POOL_BY_ID.get(m.id);
return sum + (def && isMissionComplete(m) ? def.xp : 0);
}, 0);
const bonus = areAllDailyComplete(missions) ? DAILY_BONUS_XP : 0;
return base + bonus;
}
/** XP earned by a specific daily mission (0 if incomplete or unknown) */
export function missionXp(missionId: string, mission: Mission): number {
const def = POOL_BY_ID.get(missionId);
if (!def || !isMissionComplete(mission)) return 0;
return def.xp;
}
// ─── Reroll ──────────────────────────────────────────────────────────────────
/**
* Select a replacement mission not already in the current set.
* Uses Math.random (rerolls should feel random, not deterministic).
*/
export function selectReplacementMission(
currentMissions: Mission[],
missionToReplaceId: string,
availableStages?: BlobbiStage[],
): DailyMissionDefinition | null {
const stages = availableStages ?? ['baby', 'adult'];
const excludedIds = new Set(currentMissions.map((m) => m.id));
const eligible = DAILY_MISSION_POOL.filter((m) =>
m.id !== missionToReplaceId &&
!excludedIds.has(m.id) &&
isMissionAvailableForStages(m, stages),
);
if (eligible.length === 0) return null;
const totalWeight = eligible.reduce((sum, m) => sum + m.weight, 0);
let pick = Math.random() * totalWeight;
for (const def of eligible) {
pick -= def.weight;
if (pick <= 0) return def;
}
return eligible[0];
}
/**
* Reroll a daily mission. Returns updated missions content or null if not possible.
*/
export function rerollMission(
missions: MissionsContent,
missionId: string,
availableStages?: BlobbiStage[],
): MissionsContent | null {
if (missions.rerolls <= 0) return null;
const idx = missions.daily.findIndex((m) => m.id === missionId);
if (idx === -1) return null;
const existing = missions.daily[idx];
if (isMissionComplete(existing)) return null; // can't reroll completed
const replacement = selectReplacementMission(missions.daily, missionId, availableStages);
if (!replacement) return null;
const updatedDaily = [...missions.daily];
updatedDaily[idx] = createMission(replacement);
return {
...missions,
daily: updatedDaily,
rerolls: missions.rerolls - 1,
};
}
// Re-export mission utilities for convenience
export { isTallyMission, isEventMission, isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
export type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
+68
View File
@@ -0,0 +1,68 @@
/**
* Centralized item-use cooldown tracking.
*
* Module-level singleton shared by every item-use path
* (dashboard, companion layer, shop modal, falling items).
*
* Keyed by item type ID (e.g. "food_apple"), not instance IDs.
* Separate durations for success (short) and failure (longer).
* Built-in subscriber system for React via useSyncExternalStore.
*/
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown after a successful item use (ms). */
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
/** Cooldown after a failed item use (ms). */
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
// ─── Singleton State ──────────────────────────────────────────────────────────
interface CooldownEntry {
expiresAt: number;
timerId: ReturnType<typeof setTimeout>;
}
const cooldowns = new Map<string, CooldownEntry>();
const subscribers = new Set<() => void>();
function notify(): void {
subscribers.forEach((cb) => cb());
}
// ─── Public API ───────────────────────────────────────────────────────────────
/** Check whether an item is currently on cooldown. */
export function isItemOnCooldown(itemId: string): boolean {
const entry = cooldowns.get(itemId);
if (!entry) return false;
if (Date.now() >= entry.expiresAt) {
clearTimeout(entry.timerId);
cooldowns.delete(itemId);
return false;
}
return true;
}
/** Put an item on cooldown. Notifies subscribers on start and expiry. */
export function setItemCooldown(itemId: string, success: boolean): void {
const prev = cooldowns.get(itemId);
if (prev) clearTimeout(prev.timerId);
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
const timerId = setTimeout(() => {
cooldowns.delete(itemId);
notify();
}, ms);
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
notify();
}
/** Subscribe to cooldown state changes. Returns unsubscribe function. */
export function subscribeCooldowns(callback: () => void): () => void {
subscribers.add(callback);
return () => { subscribers.delete(callback); };
}
@@ -0,0 +1,100 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Grupo das pétalas com rotação -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="10s"
repeatCount="indefinite" />
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
</g>
<!-- Grupo das partículas giratórias -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="20s"
repeatCount="indefinite" />
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.8" />
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.6" />
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.7" />
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.5" />
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.9" />
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="88" cy="105" r="8" fill="white" />
<circle cx="112" cy="105" r="8" fill="white" />
<!-- Pupils (pupil + highlights) -->
<circle cx="88" cy="105" r="5" fill="#1f2937" />
<circle cx="112" cy="105" r="5" fill="#1f2937" />
<circle cx="90" cy="103" r="2" fill="white" />
<circle cx="114" cy="103" r="2" fill="white" />
<!-- Mouth -->
<path d="M 90 120 Q 100 128 110 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Bochechas -->
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<!-- Gradientes -->
<defs>
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fed7d7" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#e0e7ff" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dcfce7" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,99 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Grupo das pétalas com rotação mais lenta (ou pode ser removido completamente) -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="360 100 110"
dur="20s"
repeatCount="indefinite" />
<circle cx="100" cy="70" r="25" fill="url(#bloomiPetal1)" />
<circle cx="130" cy="90" r="25" fill="url(#bloomiPetal2)" />
<circle cx="130" cy="130" r="25" fill="url(#bloomiPetal3)" />
<circle cx="100" cy="150" r="25" fill="url(#bloomiPetal4)" />
<circle cx="70" cy="130" r="25" fill="url(#bloomiPetal5)" />
<circle cx="70" cy="90" r="25" fill="url(#bloomiPetal6)" />
</g>
<!-- Grupo das partículas giratórias -->
<g transform="rotate(0 100 110)">
<animateTransform
attributeName="transform"
type="rotate"
from="0 100 110"
to="-360 100 110"
dur="30s"
repeatCount="indefinite" />
<circle cx="60" cy="80" r="2" fill="url(#bloomiPollen)" opacity="0.4" />
<circle cx="140" cy="85" r="1.5" fill="url(#bloomiPollen)" opacity="0.3" />
<circle cx="55" cy="140" r="1" fill="url(#bloomiPollen)" opacity="0.3" />
<circle cx="145" cy="135" r="2" fill="url(#bloomiPollen)" opacity="0.2" />
<circle cx="75" cy="60" r="1.5" fill="url(#bloomiPollen)" opacity="0.4" />
</g>
<!-- Centro da flor -->
<circle cx="100" cy="110" r="35" fill="url(#bloomiCenter)" />
<circle cx="100" cy="110" r="28" fill="url(#bloomiCenterHighlight)" opacity="0.6" />
<!-- Olhos dormindo -->
<path d="M 80 105 Q 88 108 96 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 104 105 Q 112 108 120 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Boca calma -->
<circle cx="100" cy="120" r="2" fill="#1f2937" />
<!-- Bochechas -->
<circle cx="70" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<circle cx="130" cy="115" r="8" fill="url(#bloomiBlush)" opacity="0.6" />
<!-- "Zzz" dormindo -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<!-- Gradientes -->
<defs>
<radialGradient id="bloomiPetal1" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
<radialGradient id="bloomiPetal2" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fed7d7" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="bloomiPetal3" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPetal4" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#e0e7ff" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
<radialGradient id="bloomiPetal5" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dcfce7" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="bloomiPetal6" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#dbeafe" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
<radialGradient id="bloomiCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="bloomiCenterHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="bloomiBlush" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="bloomiPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,100 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main leaf body - classic leaf shape -->
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
fill="url(#breezyBody)" />
<!-- Leaf veins - central vein -->
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
<!-- Side veins -->
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<!-- Inner leaf highlight -->
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
fill="url(#breezyInner)" opacity="0.6" />
<!-- Eyes (white base) -->
<circle cx="85" cy="90" r="10" fill="white" />
<circle cx="115" cy="90" r="10" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="85" cy="90" r="6" fill="#1f2937" />
<circle cx="115" cy="90" r="6" fill="#1f2937" />
<circle cx="87" cy="88" r="3" fill="white" />
<circle cx="117" cy="88" r="3" fill="white" />
<!-- Mouth -->
<path d="M 85 110 Q 100 120 115 110" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Little arms -->
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
<!-- Little legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<!-- Floating leaves with rotation groups -->
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.8" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.6" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.7" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.5" />
</g>
</g>
<defs>
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#86efac" />
<stop offset="30%" stop-color="#4ade80" />
<stop offset="70%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#bbf7d0" />
<stop offset="100%" stop-color="#86efac" />
</radialGradient>
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#15803d" />
<stop offset="50%" stop-color="#16a34a" />
<stop offset="100%" stop-color="#15803d" />
</linearGradient>
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,95 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main leaf body -->
<path d="M 100 40 Q 70 60 60 90 Q 55 120 70 140 Q 85 155 100 160 Q 115 155 130 140 Q 145 120 140 90 Q 130 60 100 40"
fill="url(#breezyBody)" />
<!-- Leaf veins -->
<path d="M 100 45 L 100 155" stroke="url(#breezyVein)" stroke-width="3" opacity="0.6" />
<path d="M 100 70 L 80 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 70 L 120 85" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 75 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 100 L 125 115" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 85 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<path d="M 100 130 L 115 140" stroke="url(#breezyVein)" stroke-width="2" opacity="0.5" />
<!-- Inner leaf highlight -->
<path d="M 100 50 Q 75 65 68 85 Q 65 105 75 120 Q 85 130 100 135 Q 115 130 125 120 Q 135 105 132 85 Q 125 65 100 50"
fill="url(#breezyInner)" opacity="0.6" />
<!-- Olhos dormindo -->
<path d="M 75 90 Q 85 93 95 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 105 90 Q 115 93 125 90" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Boca tranquila -->
<circle cx="100" cy="110" r="2" fill="#1f2937" />
<!-- Little arms -->
<path d="M 65 100 Q 55 95 50 105 Q 55 115 65 110" fill="url(#breezyArm)" />
<path d="M 135 100 Q 145 95 150 105 Q 145 115 135 110" fill="url(#breezyArm)" />
<!-- Little legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#breezyLeg)" />
<!-- Floating leaves -->
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
<path d="M -50 -30 Q -55 -25 -50 -20 Q -45 -25 -50 -30" fill="url(#breezyFloating)" opacity="0.6" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
<path d="M 50 -25 Q 45 -20 50 -15 Q 55 -20 50 -25" fill="url(#breezyFloating)" opacity="0.5" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
<path d="M -55 30 Q -60 35 -55 40 Q -50 35 -55 30" fill="url(#breezyFloating)" opacity="0.5" />
</g>
</g>
<g transform="translate(100 100)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
<path d="M 55 25 Q 50 30 55 35 Q 60 30 55 25" fill="url(#breezyFloating)" opacity="0.4" />
</g>
</g>
<!-- "Zzz" dormindo -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<!-- Gradientes -->
<defs>
<radialGradient id="breezyBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#86efac" />
<stop offset="30%" stop-color="#4ade80" />
<stop offset="70%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#bbf7d0" />
<stop offset="100%" stop-color="#86efac" />
</radialGradient>
<linearGradient id="breezyVein" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#15803d" />
<stop offset="50%" stop-color="#16a34a" />
<stop offset="100%" stop-color="#15803d" />
</linearGradient>
<radialGradient id="breezyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="breezyLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="breezyFloating" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,75 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cactus body -->
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
<!-- Cactus arms -->
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
<!-- Cactus ridges -->
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<!-- Eyes (white base) -->
<circle cx="90" cy="105" r="8" fill="white" />
<circle cx="110" cy="105" r="8" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="90" cy="105" r="5" fill="#1f2937" />
<circle cx="110" cy="105" r="5" fill="#1f2937" />
<circle cx="92" cy="103" r="2" fill="white" />
<circle cx="112" cy="103" r="2" fill="white" />
<!-- Mouth -->
<path d="M 92 120 Q 100 126 108 120" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Tiny spines -->
<circle cx="88" cy="90" r="1" fill="#65a30d" />
<circle cx="95" cy="95" r="1" fill="#65a30d" />
<circle cx="105" cy="92" r="1" fill="#65a30d" />
<circle cx="112" cy="88" r="1" fill="#65a30d" />
<circle cx="65" cy="110" r="1" fill="#65a30d" />
<circle cx="70" cy="120" r="1" fill="#65a30d" />
<circle cx="125" cy="115" r="1" fill="#65a30d" />
<circle cx="130" cy="125" r="1" fill="#65a30d" />
<!-- Little legs in pot -->
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
<!-- Blooming flower -->
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
<defs>
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a3e635" />
<stop offset="30%" stop-color="#84cc16" />
<stop offset="70%" stop-color="#65a30d" />
<stop offset="100%" stop-color="#4d7c0f" />
</radialGradient>
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#84cc16" />
<stop offset="100%" stop-color="#65a30d" />
</radialGradient>
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#991b1b" />
</radialGradient>
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,74 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cactus body -->
<rect x="85" y="80" width="30" height="80" rx="15" fill="url(#cactiBody)" />
<!-- Cactus arms -->
<rect x="60" y="100" width="20" height="40" rx="10" fill="url(#cactiArm)" />
<rect x="120" y="110" width="20" height="35" rx="10" fill="url(#cactiArm)" />
<!-- Cactus ridges -->
<line x1="92" y1="85" x2="92" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="100" y1="85" x2="100" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<line x1="108" y1="85" x2="108" y2="155" stroke="#65a30d" stroke-width="2" opacity="0.5" />
<!-- Sleeping eyes -->
<path d="M 82 105 Q 90 108 98 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 105 Q 110 108 118 105" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="120" r="2" fill="#1f2937" />
<!-- Tiny spines -->
<circle cx="88" cy="90" r="1" fill="#65a30d" />
<circle cx="95" cy="95" r="1" fill="#65a30d" />
<circle cx="105" cy="92" r="1" fill="#65a30d" />
<circle cx="112" cy="88" r="1" fill="#65a30d" />
<circle cx="65" cy="110" r="1" fill="#65a30d" />
<circle cx="70" cy="120" r="1" fill="#65a30d" />
<circle cx="125" cy="115" r="1" fill="#65a30d" />
<circle cx="130" cy="125" r="1" fill="#65a30d" />
<!-- Little legs in pot -->
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#cactiPot)" />
<rect x="75" y="160" width="50" height="5" fill="url(#cactiPotRim)" rx="2" />
<!-- Blooming flower -->
<circle cx="100" cy="75" r="12" fill="url(#cactiFlower)" />
<circle cx="100" cy="75" r="8" fill="url(#cactiFlowerCenter)" />
<circle cx="100" cy="75" r="4" fill="#fbbf24" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="cactiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a3e635" />
<stop offset="30%" stop-color="#84cc16" />
<stop offset="70%" stop-color="#65a30d" />
<stop offset="100%" stop-color="#4d7c0f" />
</radialGradient>
<radialGradient id="cactiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#84cc16" />
<stop offset="100%" stop-color="#65a30d" />
</radialGradient>
<radialGradient id="cactiPot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#991b1b" />
</radialGradient>
<radialGradient id="cactiPotRim" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="cactiFlower" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fce7f3" />
<stop offset="100%" stop-color="#f472b6" />
</radialGradient>
<radialGradient id="cactiFlowerCenter" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fbbf24" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
</radialGradient>
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Oval upright body -->
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
<!-- Triangle ears -->
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
<!-- Eyes (white/base eye shapes) -->
<ellipse cx="85" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
<ellipse cx="115" cy="100" rx="12" ry="16" fill="url(#cattiEyeWhite3D)" />
<!-- Pupils (pupil + highlights) -->
<ellipse cx="85" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
<ellipse cx="115" cy="100" rx="8" ry="12" fill="url(#cattiPupil3D)" />
<ellipse cx="87" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
<ellipse cx="117" cy="97" rx="3" ry="4" fill="white" opacity="0.9" />
<!-- Nose -->
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
<!-- Mouth -->
<path d="M 100 122 Q 88 128 82 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 100 122 Q 112 128 118 122" stroke="url(#cattiMouth3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Enhanced curved tail -->
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
<!-- Enhanced whiskers -->
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<!-- Soft fur texture details -->
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="cattiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="40%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEar3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEarInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f3f4f6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNose3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#be185d;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiNoseHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fce7f3;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f472b6;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTail3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#fed7aa;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f97316;stop-opacity:1" />
<stop offset="100%" style="stop-color:#c2410c;stop-opacity:1" />
</radialGradient>
<radialGradient id="cattiTailHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fed7aa;stop-opacity:1" />
</radialGradient>
<linearGradient id="cattiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1f2937;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Oval upright body -->
<ellipse cx="100" cy="120" rx="45" ry="60" fill="url(#cattiBody3D)" />
<!-- Triangle ears -->
<path d="M 68 72 L 58 48 L 82 62 Z" fill="url(#cattiEar3D)" />
<path d="M 132 72 L 142 48 L 118 62 Z" fill="url(#cattiEar3D)" />
<path d="M 70 62 L 64 52 L 76 58 Z" fill="url(#cattiEarInner)" />
<path d="M 130 62 L 136 52 L 124 58 Z" fill="url(#cattiEarInner)" />
<!-- Sleeping eyes -->
<path d="M 73 100 Q 85 103 97 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 103 100 Q 115 103 127 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Enhanced cat nose -->
<path d="M 94 115 L 100 122 L 106 115 Z" fill="url(#cattiNose3D)" />
<path d="M 96 116 L 100 120 L 104 116 Z" fill="url(#cattiNoseHighlight)" />
<!-- Peaceful mouth -->
<circle cx="100" cy="125" r="2" fill="#1f2937" />
<!-- Enhanced curved tail -->
<path d="M 145 140 Q 165 115 158 95 Q 148 75 165 65" stroke="url(#cattiTail3D)" stroke-width="22" fill="none" stroke-linecap="round" />
<path d="M 145 140 Q 163 117 156 97 Q 148 79 163 69" stroke="url(#cattiTailHighlight)" stroke-width="16" fill="none" stroke-linecap="round" />
<!-- Enhanced whiskers -->
<path d="M 48 108 Q 58 110 72 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 48 118 Q 58 120 72 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 108 Q 138 110 152 108" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<path d="M 128 118 Q 138 120 152 118" stroke="#1e293b" stroke-width="1.8" fill="none" stroke-linecap="round" />
<!-- Soft fur texture details -->
<ellipse cx="75" cy="135" rx="3" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="2.5" ry="2" fill="rgba(255,255,255,0.2)" />
<ellipse cx="90" cy="150" rx="2" ry="1.5" fill="rgba(255,255,255,0.15)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,49 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cloud body - multiple overlapping circles -->
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
<!-- Fluffy highlights -->
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="88" cy="100" r="8" fill="white" />
<circle cx="112" cy="100" r="8" fill="white" />
<!-- Pupils (pupil + highlights) -->
<circle cx="88" cy="100" r="5" fill="#64748b" />
<circle cx="112" cy="100" r="5" fill="#64748b" />
<circle cx="90" cy="98" r="2" fill="white" />
<circle cx="114" cy="98" r="2" fill="white" />
<!-- Mouth -->
<path d="M 92 115 Q 100 122 108 115" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Floating raindrops -->
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.8" />
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.6" />
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.7" />
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.5" />
<defs>
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="50%" stop-color="#f1f5f9" />
<stop offset="100%" stop-color="#e2e8f0" />
</radialGradient>
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
</radialGradient>
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
<stop offset="0%" stop-color="#60a5fa" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,51 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Main cloud body - multiple overlapping circles -->
<circle cx="100" cy="120" r="45" fill="url(#cloudiBody)" />
<circle cx="75" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="125" cy="110" r="35" fill="url(#cloudiBody)" />
<circle cx="85" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="115" cy="95" r="25" fill="url(#cloudiBody)" />
<circle cx="100" cy="85" r="30" fill="url(#cloudiBody)" />
<!-- Fluffy highlights -->
<circle cx="90" cy="100" r="20" fill="url(#cloudiHighlight)" opacity="0.6" />
<circle cx="110" cy="105" r="18" fill="url(#cloudiHighlight)" opacity="0.5" />
<circle cx="100" cy="90" r="15" fill="url(#cloudiHighlight)" opacity="0.7" />
<!-- Sleeping eyes -->
<path d="M 80 100 Q 88 103 96 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 104 100 Q 112 103 120 100" stroke="#64748b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#64748b" />
<!-- Floating raindrops with gentle animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 0,5; 0,0" dur="3s" repeatCount="indefinite" />
<circle cx="70" cy="140" r="3" fill="url(#cloudiRain)" opacity="0.6" />
<circle cx="130" cy="145" r="2.5" fill="url(#cloudiRain)" opacity="0.4" />
<circle cx="85" cy="155" r="2" fill="url(#cloudiRain)" opacity="0.5" />
<circle cx="115" cy="150" r="2.5" fill="url(#cloudiRain)" opacity="0.3" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="cloudiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="50%" stop-color="#f1f5f9" />
<stop offset="100%" stop-color="#e2e8f0" />
</radialGradient>
<radialGradient id="cloudiHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.5)" />
</radialGradient>
<radialGradient id="cloudiRain" cx="0.5" cy="0.3">
<stop offset="0%" stop-color="#60a5fa" />
<stop offset="100%" stop-color="#3b82f6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Crystal gradients -->
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</radialGradient>
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
</linearGradient>
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main crystal body - rounded hexagon shape -->
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
<!-- Crystal segments with rounded edges -->
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.8" />
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.7" />
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.7" />
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.6" />
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.6" />
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.8" />
<!-- Eyes (white base) -->
<circle cx="88" cy="95" r="10" fill="url(#crystiEye)" />
<circle cx="112" cy="95" r="10" fill="url(#crystiEye)" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="88" cy="95" r="6" fill="#1e1b4b" />
<circle cx="112" cy="95" r="6" fill="#1e1b4b" />
<circle cx="90" cy="93" r="3" fill="white" />
<circle cx="114" cy="93" r="3" fill="white" />
<!-- Mouth -->
<path d="M 90 115 Q 100 123 110 115" stroke="url(#crystiSmile)" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Floating sparkles -->
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.9" />
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.8" />
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.7" />
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.6" />
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.8" />
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.9" />
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.7" />
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.8" />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Crystal gradients -->
<radialGradient id="crystiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#f3e8ff;stop-opacity:1" />
<stop offset="30%" style="stop-color:#e9d5ff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</radialGradient>
<radialGradient id="crystiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiFacet1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet2" x1="1" y1="0" x2="0" y2="1">
<stop offset="0%" style="stop-color:#f472b6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ec4899;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet3" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#34d399;stop-opacity:1" />
<stop offset="100%" style="stop-color:#10b981;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet4" x1="0" y1="1" x2="1" y2="0">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet5" x1="1" y1="1" x2="0" y2="0">
<stop offset="0%" style="stop-color:#a78bfa;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
</linearGradient>
<linearGradient id="crystiFacet6" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" style="stop-color:#fb7185;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e11d48;stop-opacity:1" />
</linearGradient>
<radialGradient id="crystiEye" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e9d5ff;stop-opacity:1" />
</radialGradient>
<linearGradient id="crystiSmile" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="50%" style="stop-color:#c084fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main crystal body - rounded hexagon shape -->
<path d="M 100 50 L 140 80 L 140 130 L 100 160 L 60 130 L 60 80 Z" fill="url(#crystiBody)" />
<path d="M 100 55 L 135 82 L 135 128 L 100 155 L 65 128 L 65 82 Z" fill="url(#crystiInner)" opacity="0.7" />
<!-- Crystal segments with rounded edges -->
<path d="M 100 50 L 125 70 L 100 105 L 75 70 Z" fill="url(#crystiFacet1)" opacity="0.6" />
<path d="M 75 70 L 100 105 L 60 80 L 60 105 Z" fill="url(#crystiFacet2)" opacity="0.5" />
<path d="M 125 70 L 140 80 L 140 105 L 100 105 Z" fill="url(#crystiFacet3)" opacity="0.5" />
<path d="M 60 105 L 100 105 L 75 140 L 60 130 Z" fill="url(#crystiFacet4)" opacity="0.4" />
<path d="M 100 105 L 140 105 L 125 140 L 100 105 Z" fill="url(#crystiFacet5)" opacity="0.4" />
<path d="M 75 140 L 100 105 L 125 140 L 100 160 Z" fill="url(#crystiFacet6)" opacity="0.6" />
<!-- Sleeping eyes -->
<path d="M 78 95 Q 88 98 98 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 95 Q 112 98 122 95" stroke="#1e1b4b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#8b5cf6" />
<!-- Floating sparkles with gentle animation -->
<g>
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="360 100 100" dur="15s" repeatCount="indefinite" />
<circle cx="65" cy="65" r="2" fill="#fbbf24" opacity="0.6" />
<circle cx="135" cy="70" r="1.5" fill="#f472b6" opacity="0.5" />
<circle cx="70" cy="140" r="1" fill="#06b6d4" opacity="0.4" />
<circle cx="130" cy="135" r="2" fill="#fbbf24" opacity="0.3" />
</g>
<g>
<animateTransform attributeName="transform" type="rotate" from="0 100 100" to="-360 100 100" dur="20s" repeatCount="indefinite" />
<circle cx="50" cy="105" r="1.5" fill="#f472b6" opacity="0.5" />
<circle cx="150" cy="110" r="1" fill="#06b6d4" opacity="0.6" />
<circle cx="100" cy="40" r="1.5" fill="#fbbf24" opacity="0.4" />
<circle cx="100" cy="170" r="1" fill="#f472b6" opacity="0.5" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,89 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Water gradients -->
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Main water drop body -->
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
fill="url(#droppiBody)" />
<!-- Inner water reflection -->
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
<!-- Eyes (white base) -->
<circle cx="85" cy="95" r="12" fill="white" />
<circle cx="115" cy="95" r="12" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="85" cy="95" r="8" fill="#0891b2" />
<circle cx="115" cy="95" r="8" fill="#0891b2" />
<circle cx="88" cy="92" r="4" fill="white" />
<circle cx="118" cy="92" r="4" fill="white" />
<!-- Mouth -->
<path d="M 88 115 Q 100 123 112 115" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Little arms -->
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
<!-- Little legs -->
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<!-- Water droplets floating around - grouped with rotation -->
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="8s" repeatCount="indefinite" />
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.8" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="9s" repeatCount="indefinite" />
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.6" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="7s" repeatCount="indefinite" />
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.7" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="10s" repeatCount="indefinite" />
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.5" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,88 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Water gradients -->
<radialGradient id="droppiBody" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#67e8f9;stop-opacity:1" />
<stop offset="30%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="70%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiInner" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#e0f2fe;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7dd3fc;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiHighlight" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiArm" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#22d3ee;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0891b2;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiLeg" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#06b6d4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0284c7;stop-opacity:1" />
</radialGradient>
<radialGradient id="droppiDroplet" cx="0.5" cy="0.5">
<stop offset="0%" style="stop-color:#7dd3fc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
</radialGradient>
</defs>
<!-- Main water drop body -->
<path d="M 100 40 Q 100 30 100 40 Q 135 60 140 110 Q 140 150 100 165 Q 60 150 60 110 Q 65 60 100 40"
fill="url(#droppiBody)" />
<!-- Inner water reflection -->
<ellipse cx="100" cy="100" rx="35" ry="45" fill="url(#droppiInner)" opacity="0.6" />
<ellipse cx="90" cy="80" rx="20" ry="25" fill="url(#droppiHighlight)" opacity="0.7" />
<!-- Sleeping eyes -->
<path d="M 73 95 Q 85 98 97 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 103 95 Q 115 98 127 95" stroke="#0891b2" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#0891b2" />
<!-- Little arms -->
<ellipse cx="60" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(-25 60 110)" />
<ellipse cx="140" cy="110" rx="10" ry="18" fill="url(#droppiArm)" transform="rotate(25 140 110)" />
<!-- Little legs -->
<ellipse cx="85" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<ellipse cx="115" cy="160" rx="12" ry="10" fill="url(#droppiLeg)" />
<!-- Water droplets floating around - grouped with slower rotation -->
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="12s" repeatCount="indefinite" />
<circle cx="-45" cy="-35" r="3" fill="url(#droppiDroplet)" opacity="0.5" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="15s" repeatCount="indefinite" />
<circle cx="45" cy="-30" r="2.5" fill="url(#droppiDroplet)" opacity="0.4" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="10s" repeatCount="indefinite" />
<circle cx="-50" cy="25" r="2" fill="url(#droppiDroplet)" opacity="0.4" />
</g>
</g>
<g transform="translate(100 110)">
<g>
<animateTransform attributeName="transform" type="rotate" from="0" to="-360" dur="18s" repeatCount="indefinite" />
<circle cx="50" cy="20" r="2.5" fill="url(#droppiDroplet)" opacity="0.3" />
</g>
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,76 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<!-- Flame gradients -->
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fbbf24" />
<stop offset="30%" stop-color="#f97316" />
<stop offset="70%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fde047" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f97316" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fde047" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
</defs>
<!-- Larger rotating flames -->
<g transform="rotate(0 100 110)">
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.8" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="4s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.6" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="6s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.7" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="7s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.5" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="5s" repeatCount="indefinite" />
</g>
<!-- Flammy Body -->
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
<!-- Eyes (white base) -->
<circle cx="88" cy="100" r="10" fill="white" />
<circle cx="112" cy="100" r="10" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="88" cy="100" r="6" fill="#1f2937" />
<circle cx="112" cy="100" r="6" fill="#1f2937" />
<circle cx="90" cy="98" r="3" fill="white" />
<circle cx="114" cy="98" r="3" fill="white" />
<!-- Mouth -->
<path d="M 88 115 Q 100 125 112 115" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Arms -->
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
<!-- Legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,75 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<!-- Flame gradients -->
<radialGradient id="flammiBody" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fbbf24" />
<stop offset="30%" stop-color="#f97316" />
<stop offset="70%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fde047" />
<stop offset="50%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
<radialGradient id="flammiCore" cx="0.5" cy="0.4">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
<radialGradient id="flammiArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#f97316" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
<radialGradient id="flammiLeg" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="flammiEmber" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fde047" />
<stop offset="100%" stop-color="#f97316" />
</radialGradient>
</defs>
<!-- Slower rotating flames for sleeping state -->
<g transform="rotate(0 100 110)">
<path d="M45 80 Q50 65 55 80 Q50 90 45 80 Z" fill="url(#flammiEmber)" opacity="0.5" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="8s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M155 85 Q160 70 165 85 Q160 95 155 85 Z" fill="url(#flammiEmber)" opacity="0.4" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="12s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M40 130 Q45 115 50 130 Q45 140 40 130 Z" fill="url(#flammiEmber)" opacity="0.4" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="360 100 110" dur="14s" repeatCount="indefinite" />
</g>
<g transform="rotate(0 100 110)">
<path d="M160 125 Q165 110 170 125 Q165 135 160 125 Z" fill="url(#flammiEmber)" opacity="0.3" />
<animateTransform attributeName="transform" type="rotate" from="0 100 110" to="-360 100 110" dur="10s" repeatCount="indefinite" />
</g>
<!-- Flammy Body -->
<path d="M 100 160 Q 60 140 50 110 Q 45 80 70 60 Q 80 40 100 25 Q 120 40 130 60 Q 155 80 150 110 Q 140 140 100 160 Z" fill="url(#flammiBody)" />
<path d="M 100 155 Q 65 138 58 115 Q 55 90 75 70 Q 82 50 100 35 Q 118 50 125 70 Q 145 90 142 115 Q 135 138 100 155 Z" fill="url(#flammiInner)" opacity="0.8" />
<path d="M 100 145 Q 70 130 65 110 Q 62 95 80 80 Q 85 65 100 55 Q 115 65 120 80 Q 138 95 135 110 Q 130 130 100 145 Z" fill="url(#flammiCore)" opacity="0.9" />
<!-- Sleeping eyes -->
<path d="M 78 100 Q 88 103 98 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 100 Q 112 103 122 100" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="115" r="2" fill="#1f2937" />
<!-- Arms -->
<ellipse cx="55" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(-30 55 110)" />
<ellipse cx="145" cy="110" rx="8" ry="15" fill="url(#flammiArm)" transform="rotate(30 145 110)" />
<!-- Legs -->
<ellipse cx="90" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
<ellipse cx="110" cy="155" rx="10" ry="8" fill="url(#flammiLeg)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</linearGradient>
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Flattened oval body -->
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
<!-- Big circular pop-out eyes -->
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<!-- Eyes (white/base eye shapes) -->
<circle cx="70" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
<circle cx="130" cy="80" r="22" fill="url(#froggiEyeWhite3D)" />
<!-- Pupils (pupil + highlights) -->
<circle cx="70" cy="80" r="16" fill="url(#froggiPupil3D)" />
<circle cx="130" cy="80" r="16" fill="url(#froggiPupil3D)" />
<circle cx="74" cy="76" r="6" fill="white" opacity="0.9" />
<circle cx="134" cy="76" r="6" fill="white" opacity="0.9" />
<!-- Mouth -->
<path d="M 45 120 Q 100 145 155 120" stroke="url(#froggiMouth3D)" stroke-width="5" fill="none" stroke-linecap="round" />
<path d="M 50 122 Q 100 142 150 122" stroke="url(#froggiMouthHighlight)" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Enhanced nostrils -->
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<!-- Enhanced webbed feet -->
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<!-- Enhanced webbed toes -->
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Soft skin texture details -->
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Gradients for 3D effect -->
<radialGradient id="froggiBody3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="40%" style="stop-color:#22c55e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#15803d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeBase3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiEyeWhite3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
<stop offset="70%" style="stop-color:#f5f5f4;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e7e5e4;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiPupil3D" cx="0.3" cy="0.3">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e293b;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiMouth3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
<stop offset="50%" style="stop-color:#1e293b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#374151;stop-opacity:1" />
</linearGradient>
<linearGradient id="froggiMouthHighlight" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
<stop offset="50%" style="stop-color:rgba(255,255,255,0.1);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0.3);stop-opacity:1" />
</linearGradient>
<radialGradient id="froggiNostril3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiNostrilHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeet3D" cx="0.3" cy="0.2">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#16a34a;stop-opacity:1" />
</radialGradient>
<radialGradient id="froggiFeetHighlight" cx="0.4" cy="0.3">
<stop offset="0%" style="stop-color:#86efac;stop-opacity:1" />
<stop offset="100%" style="stop-color:#4ade80;stop-opacity:1" />
</radialGradient>
<linearGradient id="froggiToe3D" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style="stop-color:#16a34a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#14532d;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Flattened oval body -->
<ellipse cx="100" cy="120" rx="70" ry="50" fill="url(#froggiBody3D)" />
<!-- Big circular pop-out eyes -->
<circle cx="70" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<circle cx="130" cy="80" r="27" fill="url(#froggiEyeBase3D)" />
<!-- Sleeping eyes -->
<path d="M 54 80 Q 70 83 86 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 114 80 Q 130 83 146 80" stroke="#1e293b" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="125" r="2" fill="#1e293b" />
<!-- Enhanced nostrils -->
<ellipse cx="90" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="110" cy="110" rx="4" ry="6" fill="url(#froggiNostril3D)" />
<ellipse cx="90" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<ellipse cx="110" cy="108" rx="2" ry="3" fill="url(#froggiNostrilHighlight)" />
<!-- Enhanced webbed feet -->
<ellipse cx="60" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="140" cy="160" rx="22" ry="12" fill="url(#froggiFeet3D)" />
<ellipse cx="60" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<ellipse cx="140" cy="158" rx="18" ry="8" fill="url(#froggiFeetHighlight)" />
<!-- Enhanced webbed toes -->
<path d="M 43 160 Q 47 155 52 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 53 160 Q 57 155 62 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 63 160 Q 67 155 72 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 123 160 Q 127 155 132 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 133 160 Q 137 155 142 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<path d="M 143 160 Q 147 155 152 160" stroke="url(#froggiToe3D)" stroke-width="2.5" fill="none" stroke-linecap="round" />
<!-- Soft skin texture details -->
<ellipse cx="75" cy="135" rx="4" ry="3" fill="rgba(255,255,255,0.2)" />
<ellipse cx="125" cy="130" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.2)" />
<ellipse cx="85" cy="145" rx="3" ry="2" fill="rgba(255,255,255,0.15)" />
<ellipse cx="115" cy="140" rx="3.5" ry="2.5" fill="rgba(255,255,255,0.15)" />
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,116 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Sunflower stem -->
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
<!-- Stem leaves -->
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
<!-- Sunflower petals - outer ring -->
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
<!-- Sunflower center - outer ring -->
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
<!-- Eyes (white base) -->
<circle cx="90" cy="82" r="8" fill="white" />
<circle cx="110" cy="82" r="8" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="90" cy="82" r="5" fill="#1f2937" />
<circle cx="110" cy="82" r="5" fill="#1f2937" />
<circle cx="92" cy="80" r="2" fill="white" />
<circle cx="112" cy="80" r="2" fill="white" />
<!-- Mouth -->
<path d="M 88 92 Q 100 100 112 92" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Floating pollen -->
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.8" />
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.7" />
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.5" />
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.9" />
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.8" />
<!-- Leavy pot -->
<path d="M 75 160 L 80 175 L 120 175 L 125 160 Z" fill="url(#leafyPot)" />
<rect x="75" y="160" width="50" height="5" fill="url(#leafyPotRim)" rx="2" />
<defs>
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#4ade80" />
</radialGradient>
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
<stop offset="100%" stop-color="#eab308" />
<!-- <stop offset="70%" stop-color="#eab308" /> -->
<stop offset="30%" stop-color="#fde047" />
<stop offset="0%" stop-color="#ffce09" />
</radialGradient>
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="50%" stop-color="#92400e" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#d97706" />
<stop offset="100%" stop-color="#a16207" />
</radialGradient>
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#451a03" />
<stop offset="100%" stop-color="#292524" />
</radialGradient>
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
<radialGradient id="leafyPot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#991b1b" />
</radialGradient>
<radialGradient id="leafyPotRim" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#ef4444" />
<stop offset="100%" stop-color="#dc2626" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,113 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Sunflower stem -->
<rect x="96" y="120" width="8" height="55" fill="url(#leafyStem)" rx="4" />
<rect x="98" y="125" width="4" height="50" fill="url(#leafyStemHighlight)" rx="2" opacity="0.6" />
<!-- Stem leaves -->
<ellipse cx="85" cy="140" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(-30 85 140)" />
<ellipse cx="115" cy="150" rx="15" ry="8" fill="url(#leafyStemLeaf)" transform="rotate(30 115 150)" />
<ellipse cx="87" cy="140" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(-30 87 140)" opacity="0.7" />
<ellipse cx="113" cy="150" rx="10" ry="5" fill="url(#leafyStemLeafHighlight)" transform="rotate(30 113 150)" opacity="0.7" />
<!-- Sunflower petals - outer ring -->
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(0 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(22.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(45 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(67.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(90 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(112.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(135 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(157.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(180 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(202.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(225 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(247.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(270 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(292.5 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(315 100 85)" />
<ellipse cx="100" cy="85" rx="45" ry="12" fill="url(#leafyPetal)" transform="rotate(337.5 100 85)" />
<!-- Sunflower center - outer ring -->
<circle cx="100" cy="85" r="30" fill="url(#leafyCenter)" />
<circle cx="100" cy="85" r="25" fill="url(#leafyCenterInner)" />
<!-- Sleeping eyes -->
<path d="M 82 82 Q 90 85 98 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<path d="M 102 82 Q 110 85 118 82" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Peaceful mouth -->
<circle cx="100" cy="92" r="2" fill="#1f2937" />
<!-- Little arms - small leaves -->
<ellipse cx="60" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(-20 60 85)" />
<ellipse cx="140" cy="85" rx="12" ry="6" fill="url(#leafyArm)" transform="rotate(20 140 85)" />
<!-- Base/roots -->
<ellipse cx="95" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
<ellipse cx="105" cy="170" rx="8" ry="6" fill="url(#leafyRoot)" />
<!-- Floating pollen with gentle animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,0; 2,3; 0,0" dur="4s" repeatCount="indefinite" />
<circle cx="55" cy="60" r="2" fill="url(#leafyPollen)" opacity="0.5" />
<circle cx="145" cy="65" r="1.5" fill="url(#leafyPollen)" opacity="0.4" />
<circle cx="50" cy="110" r="1" fill="url(#leafyPollen)" opacity="0.4" />
<circle cx="150" cy="105" r="2" fill="url(#leafyPollen)" opacity="0.3" />
<circle cx="75" cy="45" r="1.5" fill="url(#leafyPollen)" opacity="0.6" />
<circle cx="125" cy="50" r="1" fill="url(#leafyPollen)" opacity="0.5" />
</g>
<!-- "Zzz" sleeping -->
<text x="150" y="60" font-size="10" fill="#666" opacity="0.8">Z</text>
<text x="157" y="55" font-size="8" fill="#666" opacity="0.6">z</text>
<text x="162" y="51" font-size="6" fill="#666" opacity="0.4">z</text>
<defs>
<radialGradient id="leafyStem" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#22c55e" />
<stop offset="100%" stop-color="#15803d" />
</radialGradient>
<radialGradient id="leafyStemHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#22c55e" />
</radialGradient>
<radialGradient id="leafyStemLeaf" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyStemLeafHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#86efac" />
<stop offset="100%" stop-color="#4ade80" />
</radialGradient>
<radialGradient id="leafyPetal" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="30%" stop-color="#fde047" />
<stop offset="100%" stop-color="#eab308" />
</radialGradient>
<radialGradient id="leafyCenter" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="50%" stop-color="#92400e" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyCenterInner" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#d97706" />
<stop offset="100%" stop-color="#a16207" />
</radialGradient>
<radialGradient id="leafySeed" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#451a03" />
<stop offset="100%" stop-color="#292524" />
</radialGradient>
<radialGradient id="leafyArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#4ade80" />
<stop offset="100%" stop-color="#16a34a" />
</radialGradient>
<radialGradient id="leafyRoot" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#a16207" />
<stop offset="100%" stop-color="#78350f" />
</radialGradient>
<radialGradient id="leafyPollen" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="100%" stop-color="#fde047" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,72 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- Mushroom stem -->
<ellipse cx="100" cy="140" rx="25" ry="40" fill="url(#mushieStem)" />
<ellipse cx="100" cy="135" rx="20" ry="35" fill="url(#mushieStemHighlight)" opacity="0.6" />
<!-- Mushroom cap -->
<path d="M 50 110 Q 50 70 100 60 Q 150 70 150 110 Z" fill="url(#mushieCap)" />
<path d="M 55 108 Q 55 75 100 65 Q 145 75 145 108 Z" fill="url(#mushieCapHighlight)" opacity="0.7" />
<!-- Cap spots -->
<circle cx="80" cy="85" r="8" fill="white" opacity="0.8" />
<circle cx="120" cy="80" r="10" fill="white" opacity="0.8" />
<circle cx="100" cy="95" r="6" fill="white" opacity="0.7" />
<circle cx="130" cy="100" r="5" fill="white" opacity="0.6" />
<circle cx="70" cy="100" r="5" fill="white" opacity="0.6" />
<!-- Eyes (white base) -->
<circle cx="88" cy="130" r="8" fill="white" />
<circle cx="112" cy="130" r="8" fill="white" />
<!-- Pupils (dark circles + highlights) -->
<circle cx="88" cy="130" r="5" fill="#1f2937" />
<circle cx="112" cy="130" r="5" fill="#1f2937" />
<circle cx="90" cy="128" r="2" fill="white" />
<circle cx="114" cy="128" r="2" fill="white" />
<!-- Mouth -->
<path d="M 88 145 Q 100 153 112 145" stroke="#1f2937" stroke-width="3" fill="none" stroke-linecap="round" />
<!-- Little arms -->
<ellipse cx="70" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(-20 70 140)" />
<ellipse cx="130" cy="140" rx="8" ry="12" fill="url(#mushieArm)" transform="rotate(20 130 140)" />
<!-- Floating spores -->
<circle cx="55" cy="120" r="2" fill="url(#mushieSpore)" opacity="0.8" />
<circle cx="145" cy="115" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
<circle cx="50" cy="90" r="1" fill="url(#mushieSpore)" opacity="0.7" />
<circle cx="150" cy="95" r="2" fill="url(#mushieSpore)" opacity="0.5" />
<circle cx="65" cy="70" r="1.5" fill="url(#mushieSpore)" opacity="0.6" />
<circle cx="135" cy="65" r="1" fill="url(#mushieSpore)" opacity="0.8" />
<defs>
<radialGradient id="mushieStem" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#fef3c7" />
<stop offset="30%" stop-color="#fde68a" />
<stop offset="70%" stop-color="#fbbf24" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="mushieStemHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#ffffff" />
<stop offset="100%" stop-color="rgba(255,255,255,0.3)" />
</radialGradient>
<radialGradient id="mushieCap" cx="0.3" cy="0.2">
<stop offset="0%" stop-color="#f87171" />
<stop offset="30%" stop-color="#ef4444" />
<stop offset="70%" stop-color="#dc2626" />
<stop offset="100%" stop-color="#b91c1c" />
</radialGradient>
<radialGradient id="mushieCapHighlight" cx="0.4" cy="0.3">
<stop offset="0%" stop-color="#fca5a5" />
<stop offset="100%" stop-color="#f87171" />
</radialGradient>
<radialGradient id="mushieArm" cx="0.3" cy="0.3">
<stop offset="0%" stop-color="#fde68a" />
<stop offset="100%" stop-color="#f59e0b" />
</radialGradient>
<radialGradient id="mushieSpore" cx="0.5" cy="0.5">
<stop offset="0%" stop-color="#c084fc" />
<stop offset="100%" stop-color="#8b5cf6" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

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