Compare commits

...

83 Commits

Author SHA1 Message Date
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
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
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
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
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
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
93 changed files with 9473 additions and 5239 deletions
+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.
+2 -1
View File
@@ -54,7 +54,6 @@ deploy-nsite:
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
--fallback "/index.html"
--publish-server-list
--use-fallback-relays
--use-fallback-servers
@@ -203,6 +202,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"
BLOSSOM_URL: "https://blossom.ditto.pub"
script:
- go install github.com/zapstore/zsp@latest
+37
View File
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### 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.
+69 -6
View File
@@ -1,5 +1,68 @@
# Changelog
## [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
@@ -36,7 +99,7 @@
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -91,11 +154,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- 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 kind 10008 profile badge lists
- 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
@@ -114,7 +177,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- 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
@@ -141,10 +204,10 @@
- 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
- NIP-11 relay information panel on the network settings page
- 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 and NIP-46 users on Android
- 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
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.2.9"
versionName "2.4.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+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
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.4.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.2.9;
MARKETING_VERSION = 2.4.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+1878 -20
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.2.9",
"version": "2.4.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -53,6 +53,17 @@
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@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.0",
"@nostrify/react": "^0.4.0",
"@nostrify/types": "^0.36.9",
@@ -117,6 +128,7 @@
"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",
+69 -6
View File
@@ -1,5 +1,68 @@
# Changelog
## [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
@@ -36,7 +99,7 @@
### Added
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
- Zap receipts and profile metadata events now render in feeds and detail pages
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
### Changed
- Post action buttons extracted into a reusable PostActionBar component
@@ -91,11 +154,11 @@
## [2.2.2] - 2026-03-29
### Added
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
- 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 kind 10008 profile badge lists
- 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
@@ -114,7 +177,7 @@
- Double-tap reactions now properly show the emoji on the post
- Emoji shortcode autocomplete text and highlight colors
- Profile skeleton no longer flickers for brand-new users with no metadata
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
- 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
@@ -141,10 +204,10 @@
- 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
- NIP-11 relay information panel on the network settings page
- 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 and NIP-46 users on Android
- 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
+22 -5
View File
@@ -16,12 +16,14 @@ import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -148,15 +150,30 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
};
/**
* Parse and validate build-time ditto.json overrides from the env string.
* Returns an empty object when no config file was provided or validation fails.
*/
function parseDittoConfig(): DittoConfig {
try {
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
if (!json) return {};
return DittoConfigSchema.parse(json);
} catch {
return {};
}
}
/**
* Merge hardcoded defaults with build-time ditto.json overrides.
* Deep-merges feedSettings so a partial override doesn't erase defaults.
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
*/
const dittoConfig = parseDittoConfig();
const defaultConfig: AppConfig = {
...hardcodedConfig,
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
? __DITTO_CONFIG__
: {}),
...dittoConfig,
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
};
export function App() {
@@ -184,11 +201,11 @@ export function App() {
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
<Toaster />
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
+8
View File
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { getExtraKindDef } from "./lib/extraKinds";
@@ -32,6 +34,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
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 })));
@@ -136,6 +139,8 @@ export function AppRouter() {
return (
<AudioPlayerProvider>
<BrowserRouter>
<Toaster />
<VersionCheck />
<MinimizedAudioBar />
<AudioNavigationGuard />
<DeepLinkHandler />
@@ -207,6 +212,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={
@@ -214,6 +221,7 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
@@ -1,19 +1,39 @@
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
/**
* Missions modal for Blobbi.
*
* Shows:
* - Daily missions (always visible, separate reward system)
* - Incubation tasks when the current Blobbi is incubating (egg stage)
* - Evolve tasks when evolving (baby stage)
* 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 { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
import {
Loader2,
XCircle,
AlertTriangle,
Coins,
X,
Eye,
Scroll,
Compass,
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { formatCompactNumber, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
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,
@@ -24,7 +44,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useState } from 'react';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
@@ -42,36 +61,86 @@ import { useRerollMission } from '../hooks/useRerollMission';
interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Current companion being viewed */
companion: BlobbiCompanion;
/** Current Blobbonaut profile (required for coin updates) */
profile: BlobbonautProfile | null;
/** Callback to update profile in query cache after claiming */
updateProfileEvent: (event: NostrEvent) => void;
/** Hatch tasks result from useHatchTasks */
hatchTasks: HatchTasksResult;
/** Evolve tasks result from useEvolveTasks */
evolveTasks: EvolveTasksResult;
/** Called when user clicks "Create Post" action in tasks */
onOpenPostModal: () => void;
/** Called when all hatch tasks are complete and user clicks "Hatch" */
onHatch: () => void;
/** Whether hatching is in progress */
isHatching: boolean;
/** Called when all evolve tasks are complete and user clicks "Evolve" */
onEvolve: () => void;
/** Whether evolving is in progress */
isEvolving: boolean;
/** Called when user confirms stopping incubation */
onStopIncubation: () => Promise<void>;
/** Whether stop incubation is in progress */
isStoppingIncubation: boolean;
/** Called when user confirms stopping evolution */
onStopEvolution: () => Promise<void>;
/** Whether stop evolution is in progress */
isStoppingEvolution: boolean;
/** Available Blobbi stages across all user's companions (for mission filtering) */
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 ───────────────────────────────────────────────────
@@ -79,14 +148,20 @@ interface BlobbiMissionsModalProps {
interface DailyMissionsSectionProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
/** Available Blobbi stages the user has */
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
function DailyMissionsSection({
profile,
updateProfileEvent,
availableStages,
disabled,
defaultOpen = true,
}: DailyMissionsSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const {
missions,
todayClaimedReward,
@@ -100,58 +175,56 @@ function DailyMissionsSection({ profile, updateProfileEvent, availableStages, di
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent
updateProfileEvent,
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const handleClaimReward = (missionId: string) => {
claimReward({ missionId });
};
const handleRerollMission = (missionId: string) => {
rerollMission({ missionId, availableStages });
};
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-sm">Daily Missions</h3>
<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">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0" />
<span className="whitespace-nowrap">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
<span className="tabular-nums">
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
{claimableCount > 0 && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
{claimableCount}
</span>
)}
</div>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
{/* Mission list */}
<CollapsibleContent className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={handleClaimReward}
onRerollMission={handleRerollMission}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={(id) => claimReward({ missionId: id })}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
/>
</div>
</CollapsibleContent>
</Collapsible>
);
@@ -224,9 +297,9 @@ function StopConfirmationDialog({
);
}
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
interface ProcessContentProps {
interface CurrentFocusSectionProps {
companion: BlobbiCompanion;
tasks: HatchTasksResult | EvolveTasksResult;
processType: 'incubation' | 'evolution';
@@ -238,7 +311,7 @@ interface ProcessContentProps {
defaultOpen?: boolean;
}
function ProcessContent({
function CurrentFocusSection({
companion,
tasks,
processType,
@@ -248,93 +321,98 @@ function ProcessContent({
onStop,
isStopping,
defaultOpen = true,
}: ProcessContentProps) {
}: CurrentFocusSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
const isIncubation = processType === 'incubation';
const emoji = isIncubation ? '🥚' : '🐣';
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
const description = isIncubation
? 'Complete these tasks to hatch your Blobbi'
: 'Complete these tasks to evolve your Blobbi';
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 completedCount = tasks.tasks.filter((t) => t.completed).length;
const totalTasks = tasks.tasks.length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
{/* Section header - Clickable */}
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
{/* Section header — tappable */}
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
<div className="flex items-center justify-between py-1 group">
<div className="flex items-center gap-2">
<span className="text-lg">{emoji}</span>
<h3 className="font-semibold text-sm">{title}</h3>
<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 px-2 py-0.5 rounded-full",
tasks.allCompleted
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground"
)}>
{completedCount}/{totalTasks}
<span
className={cn(
'text-xs font-medium tabular-nums',
tasks.allCompleted
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
>
{completedCount} / {totalTasks}
</span>
<ChevronDown className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)} />
<SectionChevron open={isOpen} />
</div>
</div>
</CollapsibleTrigger>
{/* Tasks content */}
<CollapsibleContent className="pt-3">
{/* Tasks Panel */}
<TasksPanel
tasks={tasks.tasks}
allCompleted={tasks.allCompleted}
isLoading={tasks.isLoading}
onOpenPostModal={onOpenPostModal}
onComplete={onComplete}
isCompleting={isCompleting}
emoji={emoji}
title={title}
description={description}
completeLabel={completeLabel}
completingLabel={completingLabel}
completeEmoji={completeEmoji}
/>
<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 Button */}
<div className="mt-6 pt-4 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirmation(true)}
disabled={isStopping || isCompleting}
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{isStopping ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Stopping...
</>
) : (
<>
<XCircle className="size-4 mr-2" />
{stopLabel}
</>
)}
</Button>
{/* 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>
{/* Stop Confirmation Dialog */}
<StopConfirmationDialog
open={showStopConfirmation}
onOpenChange={setShowStopConfirmation}
@@ -347,6 +425,17 @@ function ProcessContent({
);
}
// ─── 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({
@@ -367,54 +456,46 @@ export function BlobbiMissionsModal({
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';
// Check if there's an active hatch/evolve process
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">
{/* 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 className="flex items-center gap-2">
<Target className="size-5 shrink-0" />
Missions
</DialogTitle>
<DialogDescription className="break-words">
Complete missions to earn rewards for {companion.name}
</DialogDescription>
{/* ── 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>
<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>
</div>
{/* Content - Scrollable */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
{/* Daily Missions Section - Always visible, expanded by default */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
defaultOpen={true}
/>
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
{hasActiveProcess && (
{/* ── 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 ? (
<ProcessContent
<CurrentFocusSection
companion={companion}
tasks={hatchTasks}
processType="incubation"
@@ -423,10 +504,9 @@ export function BlobbiMissionsModal({
isCompleting={isHatching}
onStop={onStopIncubation}
isStopping={isStoppingIncubation}
defaultOpen={true}
/>
) : isEvolvingState && isBaby ? (
<ProcessContent
<CurrentFocusSection
companion={companion}
tasks={evolveTasks}
processType="evolution"
@@ -435,10 +515,43 @@ export function BlobbiMissionsModal({
isCompleting={isEvolving}
onStop={onStopEvolution}
isStopping={isStoppingEvolution}
defaultOpen={true}
/>
) : null}
</>
) : (
<EmptyFocusState />
)}
{/* Divider */}
<div className="h-px bg-border/60" />
{/* 2. Daily Bounties */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
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>
@@ -30,6 +30,7 @@ import { toast } from '@/hooks/useToast';
import {
BLOBBI_POST_REQUIRED_HASHTAGS,
buildHatchPhrase,
} from '../hooks/useHatchTasks';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -49,33 +50,13 @@ interface BlobbiPostModalProps {
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Sanitize a name into a valid hashtag format.
* - Removes special characters
* - Replaces spaces with nothing (camelCase-like)
* - Ensures lowercase
* - Handles edge cases
*/
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';
}
/**
* Build the required prefix text based on process type.
*/
function buildPrefix(process: BlobbiPostProcess): string {
return process === 'evolve'
? 'Hello Nostr! Posting to evolve'
: 'Hello Nostr! Posting to hatch';
? 'Posting to evolve'
: 'Posting to hatch';
}
// ─── Main Component ───────────────────────────────────────────────────────────
@@ -91,20 +72,19 @@ export function BlobbiPostModal({
const { mutateAsync: createEvent, isPending } = useNostrPublish();
// Compute the required elements based on props
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
const prefix = useMemo(() => buildPrefix(process), [process]);
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
// All required hashtags including the Blobbi name (first)
const allRequiredHashtags = useMemo(() =>
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
[blobbiHashtag]
// 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
const defaultContent = useMemo(() =>
`${prefix} #${allRequiredHashtags.join(' #')}`,
[prefix, allRequiredHashtags]
);
// 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);
@@ -118,24 +98,14 @@ export function BlobbiPostModal({
}, [open, defaultContent]);
/**
* Validate that the content still contains the required prefix and hashtags.
* Validate that the content contains the required phrase.
*/
const validateContent = useCallback((text: string): string | null => {
// Check prefix
if (!text.startsWith(prefix)) {
return 'The post must start with the required text';
if (!text.includes(requiredPhrase)) {
return `The post must contain: "${requiredPhrase}"`;
}
// Check all required hashtags are present (including Blobbi name)
const lowerText = text.toLowerCase();
for (const tag of allRequiredHashtags) {
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
return `Missing required hashtag: #${tag}`;
}
}
return null;
}, [prefix, allRequiredHashtags]);
}, [requiredPhrase]);
/**
* Handle content change with validation.
@@ -180,21 +150,26 @@ export function BlobbiPostModal({
}
try {
// Build tags for the post
// Build tags for the post: extract all hashtags from content
const tags: string[][] = [];
const seen = new Set<string>();
// Add all required hashtags as 't' tags
for (const hashtag of allRequiredHashtags) {
tags.push(['t', hashtag.toLowerCase()]);
// 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 the user added
const additionalHashtags = content.match(/#(\w+)/g) || [];
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
for (const tag of additionalHashtags) {
// 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 (!requiredLower.includes(tagValue)) {
if (!seen.has(tagValue)) {
tags.push(['t', tagValue]);
seen.add(tagValue);
}
}
@@ -220,7 +195,7 @@ export function BlobbiPostModal({
variant: 'destructive',
});
}
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
const canPost = !validationError && content.trim().length > 0;
@@ -282,13 +257,9 @@ export function BlobbiPostModal({
{/* 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 content:</p>
<p className="text-sm font-medium">
<span className="text-primary">{prefix}</span>
{' '}
{allRequiredHashtags.map(tag => (
<span key={tag} className="text-blue-500">#{tag} </span>
))}
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
<p className="text-sm font-medium text-primary">
{requiredPhrase}
</p>
</div>
</div>
@@ -1,285 +1,164 @@
/**
* DailyMissionsPanel - UI component for displaying daily missions
*
* Shows:
* - Daily mission list with progress bars
* - Completion state
* - Claim buttons for completed missions
* - Coin rewards
* - Bonus mission after completing all regular missions
* - Empty state when no missions available (egg-only users)
* - Reroll button to replace missions (max 3/day)
* 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, claim button, and reroll.
* Only one card expanded at a time.
*/
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import {
Check,
Coins,
Gift,
Sparkles,
Egg,
Trophy,
RefreshCw,
Heart,
Utensils,
Droplets,
Moon,
Camera,
Mic,
Music,
Pill,
CircleDot,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMission } from '../lib/daily-missions';
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
/** The daily missions to display */
missions: DailyMission[];
/** Callback when claiming a mission reward */
onClaimReward: (missionId: string) => void;
/** Callback when rerolling a mission */
onRerollMission?: (missionId: string) => void;
/** Total coins earned today */
todayCoins: number;
/** Whether claiming is disabled (e.g., during another operation) */
disabled?: boolean;
/** Whether the bonus mission is available */
bonusAvailable?: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed?: boolean;
/** Bonus mission reward amount */
bonusReward?: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable?: boolean;
/** Number of rerolls remaining today */
rerollsRemaining?: number;
/** Whether a reroll is currently in progress */
isRerolling?: boolean;
}
// ─── Mission Item ─────────────────────────────────────────────────────────────
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
interface MissionItemProps {
mission: DailyMission;
onClaim: () => void;
onReroll?: () => void;
disabled?: boolean;
canReroll?: boolean;
isRerolling?: boolean;
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} />;
}
}
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
const canClaim = mission.completed && !mission.claimed;
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
// ─── Bonus Card ───────────────────────────────────────────────────────────────
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
mission.claimed
? 'bg-primary/5 border-primary/20'
: mission.completed
? 'bg-green-500/5 border-green-500/30'
: 'bg-card border-border'
)}
>
{/* Top right area: Claimed badge OR Reroll button */}
<div className="absolute top-2 right-2">
{mission.claimed ? (
<div className="flex items-center gap-1 text-xs text-primary font-medium">
<Check className="size-3" />
Claimed
</div>
) : showRerollButton ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={onReroll}
disabled={disabled || isRerolling}
>
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Replace this mission</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className="pr-14 sm:pr-16">
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
<p className="text-xs text-muted-foreground mt-0.5 break-words">
{mission.description}
</p>
</div>
{/* Progress bar */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs gap-2">
<span className="text-muted-foreground whitespace-nowrap">
{mission.currentCount} / {mission.requiredCount}
</span>
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
<Coins className="size-3 shrink-0" />
{formatCompactNumber(mission.reward)}
</span>
</div>
<Progress
value={progressPercent}
className={cn(
'h-2',
mission.completed && '[&>div]:bg-green-500'
)}
/>
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-green-600 hover:bg-green-700 text-white"
>
<Gift className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
</Button>
)}
</div>
</div>
);
}
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
interface BonusMissionItemProps {
interface BonusCardProps {
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
return (
<div
className={cn(
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
isClaimed
? 'bg-amber-500/10 border-amber-500/30'
: isAvailable
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
: 'bg-muted/30 border-dashed border-muted-foreground/20'
)}
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isClaimed}
progress={progress}
isExpanded={isExpanded}
onToggle={onToggle}
>
{/* Claimed badge */}
{isClaimed && (
<div className="absolute top-2 right-2">
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
<Check className="size-3" />
Claimed
</div>
</div>
)}
<MissionDescription>
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
{/* Mission content */}
<div className="space-y-2 sm:space-y-3">
{/* Title and description */}
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
<div className="flex items-center gap-2">
<Trophy className={cn(
"size-4 shrink-0",
isClaimed
? "text-amber-600 dark:text-amber-400"
: isAvailable
? "text-amber-500"
: "text-muted-foreground"
)} />
<h4 className="font-medium text-sm">Daily Champion</h4>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions above to unlock this bonus'}
</p>
</div>
{/* Reward display */}
<div className="flex items-center justify-between text-xs gap-2">
<span className={cn(
"text-muted-foreground",
!isAvailable && !isClaimed && "opacity-50"
)}>
Bonus Reward
</span>
<span className={cn(
"flex items-center gap-1 font-medium",
isClaimed || isAvailable
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground"
)}>
<Coins className="size-3 shrink-0" />
+{formatCompactNumber(reward)}
</span>
</div>
{/* Claim button */}
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
>
<Trophy className="size-4 mr-2 shrink-0" />
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
</Button>
)}
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
+{formatCompactNumber(reward)}
</div>
</div>
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
>
<Trophy className="size-3.5 mr-1.5" />
Claim Bonus {formatCompactNumber(reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
}
// ─── No Missions Available State ──────────────────────────────────────────────
// ─── Empty / Done States ──────────────────────────────────────────────────────
function NoMissionsState() {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Egg className="size-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
<p className="text-xs text-muted-foreground">
Daily missions will be available once you have
<br />
a hatched Blobbi to interact with!
<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>
);
}
// ─── All Claimed State ────────────────────────────────────────────────────────
interface AllClaimedStateProps {
todayCoins: number;
}
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
return (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-6 text-primary" />
</div>
<div className="space-y-1">
<h4 className="font-semibold text-sm">All Done for Today!</h4>
<p className="text-xs text-muted-foreground">
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
<br />
Come back tomorrow for new missions!
<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-amber-600 dark:text-amber-400">
{formatCompactNumber(todayCoins)} coins
</span>{' '}
come back tomorrow!
</p>
</div>
</div>
@@ -288,20 +167,17 @@ function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
// ─── Reroll Counter ───────────────────────────────────────────────────────────
interface RerollCounterProps {
remaining: number;
}
function RerollCounter({ remaining }: { remaining: number }) {
const text =
remaining === 0
? 'No rerolls left'
: remaining === 1
? '1 reroll left'
: `${remaining} rerolls left`;
function RerollCounter({ remaining }: RerollCounterProps) {
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.5 text-xs text-muted-foreground">
<RefreshCw className="size-3" />
<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>
);
@@ -322,48 +198,121 @@ export function DailyMissionsPanel({
rerollsRemaining = 0,
isRerolling = false,
}: DailyMissionsPanelProps) {
// Show empty state if user has no eligible missions (e.g., only eggs)
if (noMissionsAvailable) {
return <NoMissionsState />;
}
const [expandedId, setExpandedId] = useState<string | null>(null);
if (noMissionsAvailable) return <NoMissionsState />;
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
// Show "all done" state only when everything including bonus is claimed
if (allDone) {
return <AllClaimedState todayCoins={todayCoins} />;
}
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
const handleToggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<div className="space-y-3">
{/* Reroll counter - only show if reroll functionality is available */}
{onRerollMission && (
<RerollCounter remaining={rerollsRemaining} />
)}
{/* Regular missions */}
{missions.map((mission) => (
<MissionItem
key={mission.id}
mission={mission}
onClaim={() => onClaimReward(mission.id)}
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
disabled={disabled}
canReroll={canReroll}
isRerolling={isRerolling}
/>
))}
{/* Bonus mission - always visible */}
<BonusMissionItem
<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 progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
const canClaim = mission.completed && !mission.claimed;
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
return (
<ExpandableMissionCard
key={mission.id}
id={mission.id}
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.claimed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
{/* Description */}
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.claimed && (
<MissionProgress
current={mission.currentCount}
required={mission.requiredCount}
completed={mission.completed}
/>
)}
{/* Reward + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
{formatCompactNumber(mission.reward)}
</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.claimed && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
</span>
)}
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onClaimReward(mission.id);
}}
disabled={disabled}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
>
<Gift className="size-3.5 mr-1.5" />
Claim {formatCompactNumber(mission.reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
})}
{/* Bonus card */}
<BonusCard
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
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>
);
}
+149 -217
View File
@@ -1,22 +1,38 @@
// src/blobbi/actions/components/TasksPanel.tsx
/**
* Generic UI component for displaying task progress.
* Shows a list of tasks with progress indicators and action buttons.
* Used for both hatch and evolve tasks.
* 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 { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
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 { 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';
import type { MissionCategory } from './ExpandableMissionCard';
import {
ExpandableMissionCard,
MissionDescription,
MissionProgress,
MissionAction,
DynamicHint,
} from './ExpandableMissionCard';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -24,149 +40,38 @@ interface TasksPanelProps {
tasks: HatchTask[];
allCompleted: boolean;
isLoading: boolean;
/** Called when user clicks "Create Post" action */
onOpenPostModal: () => void;
/** Called when all tasks are complete and user clicks the complete button */
onComplete: () => void;
/** Whether completion is in progress */
isCompleting?: boolean;
/** Emoji to show in header */
emoji: string;
/** Title for the tasks panel */
title: string;
/** Description for the tasks panel */
description: string;
/** Label for the complete button */
completeLabel: string;
/** Label while completing */
completingLabel: string;
/** Emoji for complete button */
completeEmoji: string;
/** Mission category for styling the cards */
category?: MissionCategory;
}
// ─── Task Row Component ───────────────────────────────────────────────────────
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
interface TaskRowProps {
task: HatchTask;
onOpenPostModal: () => void;
}
/** Map task ids to lucide icons. Falls back to a generic icon. */
function TaskIcon({ taskId }: { taskId: string }) {
const iconClass = 'size-5';
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
const navigate = useNavigate();
const isDynamic = task.type === 'dynamic';
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 flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
task.completed
? "bg-emerald-500/5 border-emerald-500/20"
: isDynamic
? "bg-amber-500/5 border-amber-500/20"
: "bg-card/60 border-border hover:border-primary/30"
)}
>
{/* Top row on mobile: Status + Task info */}
<div className="flex items-start sm:items-center gap-3 sm:contents">
{/* Status indicator */}
<div className={cn(
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
task.completed
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: isDynamic
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
: "bg-muted text-muted-foreground"
)}>
{task.completed ? (
<Check className="size-4 sm:size-5" />
) : isDynamic ? (
<AlertCircle className="size-4 sm:size-5" />
) : task.required > 1 ? (
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
) : (
<span className="text-base sm:text-lg"></span>
)}
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
<h4 className={cn(
"font-medium text-sm sm:text-base break-words",
task.completed && "text-emerald-600 dark:text-emerald-400",
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
)}>
{task.name}
</h4>
{task.completed && (
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
Complete
</Badge>
)}
{isDynamic && !task.completed && (
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
Live
</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{task.description}
</p>
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
{task.required > 1 && !task.completed && !isDynamic && (
<Progress value={progress} className="h-1.5 mt-2" />
)}
{/* Dynamic task hint */}
{isDynamic && !task.completed && (
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
Lowest stat: {task.current}% (need {task.required}%+)
</p>
)}
</div>
</div>
{/* Action button - full width on mobile when present */}
{task.action && task.actionLabel && !task.completed && (
<Button
variant="outline"
size="sm"
onClick={handleAction}
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
>
<span className="truncate">{task.actionLabel}</span>
{task.action === 'external_link' ? (
<ExternalLink className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
</Button>
)}
</div>
);
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 ───────────────────────────────────────────────────────────
@@ -178,86 +83,113 @@ export function TasksPanel({
onOpenPostModal,
onComplete,
isCompleting = false,
emoji,
title,
description,
completeLabel,
completingLabel,
completeEmoji,
category = 'hatch',
}: TasksPanelProps) {
const completedCount = tasks.filter(t => t.completed).length;
const totalTasks = tasks.length;
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
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 (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
<span className="break-words">{title}</span>
</CardTitle>
<CardDescription className="text-xs sm:text-sm break-words">
{description}
</CardDescription>
</div>
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
{completedCount}/{totalTasks}
</Badge>
</div>
{/* Overall progress */}
<div className="mt-3 sm:mt-4">
<div className="flex items-center justify-between text-xs sm: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-2 sm:space-y-3 px-3 sm:px-6">
{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}
/>
))}
{/* Complete button - only visible when all tasks complete */}
{allCompleted && (
<div className="pt-4 border-t border-border mt-4">
<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"
>
{isCompleting ? (
<>
<Loader2 className="size-5 animate-spin" />
{completingLabel}
</>
) : (
<>
<span className="text-xl">{completeEmoji}</span>
{completeLabel}
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
<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>
);
}
+19 -18
View File
@@ -30,8 +30,8 @@ import {
// ─── Constants ────────────────────────────────────────────────────────────────
/** Kind for wall edit events */
export const KIND_WALL_EDIT = 16769;
/** Kind for custom profile tabs event */
export const KIND_PROFILE_TABS = 16769;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
@@ -117,7 +117,7 @@ export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolea
* 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 Wall once (kind 16769)
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 6. Maintain All Stats >= 80
@@ -165,14 +165,14 @@ export function useEvolveTasks(
since: stateStartedAt,
limit: 50, // Only need 1 valid evolve post
},
// Wall edits after start
// Custom profile tabs after start
{
kinds: [KIND_WALL_EDIT],
kinds: [KIND_PROFILE_TABS],
authors: [pubkey],
since: stateStartedAt,
limit: 1, // Only need 1
},
// Profile metadata after start (for Blobbi shape check)
// Profile metadata after start (for Blobbi shape check + profile edit mission)
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
@@ -197,8 +197,8 @@ export function useEvolveTasks(
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
const wallEditEvents = events.filter(e =>
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
const profileTabsEvents = events.filter(e =>
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
);
// Get latest profile after start
@@ -211,7 +211,7 @@ export function useEvolveTasks(
themeEvents,
colorMomentEvents,
postEvents,
wallEditEvents,
profileTabsEvents,
profileAfter,
};
},
@@ -287,20 +287,21 @@ export function useEvolveTasks(
// No action - just interact with Blobbi
});
// 5. Edit Wall once (PERSISTENT)
const wallEditCount = data?.wallEditEvents?.length ?? 0;
const hasWallEdit = wallEditCount >= 1;
// 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_wall',
name: 'Edit Your Wall',
description: 'Customize your profile wall',
current: hasWallEdit ? 1 : 0,
id: 'edit_profile',
name: 'Edit Your Profile',
description: 'Update your profile info or customize your profile tabs',
current: hasProfileEdit ? 1 : 0,
required: 1,
completed: hasWallEdit,
completed: hasProfileEdit,
type: 'persistent',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Wall',
actionLabel: 'Edit Profile',
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
+21 -15
View File
@@ -34,10 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1;
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', 'ditto', 'nostr'];
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
/** Prefix text for Blobbi hatch post */
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
/** 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;
@@ -110,16 +110,28 @@ export interface HatchTasksResult {
// ─── 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.
* Must contain the required prefix and all required hashtags including the Blobbi name.
* 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 (will be sanitized and checked as hashtag)
* @param blobbiName - The Blobbi's name
*/
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with prefix
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
const phrase = buildHatchPhrase(blobbiName);
// The phrase must appear somewhere in the content
if (!event.content.includes(phrase)) {
return false;
}
@@ -128,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
// All required hashtags must be present as t tags
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);
return hasRequiredHashtags;
}
// Legacy function name for backwards compatibility
+2 -1
View File
@@ -57,6 +57,7 @@ export {
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
buildHatchPhrase,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
@@ -70,7 +71,7 @@ export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_WALL_EDIT,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
+39 -11
View File
@@ -976,7 +976,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
event,
d,
currentCompanion: getTagValue(tags, 'current_companion'),
onboardingDone: parseBooleanTag(tags, 'onboarding_done', false),
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|| parseBooleanTag(tags, 'onboarding_done', false),
name: getTagValue(tags, 'name'),
has: getTagValues(tags, 'has'),
coins: parseNumericTag(tags, 'coins') ?? 0,
@@ -996,7 +997,7 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
return [
['d', getCanonicalBlobbonautD(pubkey)],
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
['onboarding_done', 'false'],
['blobbi_onboarding_done', 'false'],
['pettingLevel', '0'],
];
}
@@ -1138,7 +1139,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
* These tags are controlled by the application and may be overwritten.
*/
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage',
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
// Legacy player progress tags (preserved for compatibility)
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
@@ -1365,17 +1366,44 @@ export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile
}
/**
* Build updated tags for normalizing a profile to include pettingLevel.
* Preserves all existing tags and adds pettingLevel: 0 if missing.
* Check if a profile uses the legacy `onboarding_done` tag instead of the
* new `blobbi_onboarding_done` tag. Returns true if migration is needed.
*/
export function profileNeedsOnboardingTagMigration(profile: BlobbonautProfile): boolean {
const hasNewTag = profile.allTags.some(([name]) => name === 'blobbi_onboarding_done');
const hasOldTag = profile.allTags.some(([name]) => name === 'onboarding_done');
// Needs migration if: has old tag but not the new one
return !hasNewTag && hasOldTag;
}
/**
* Build updated tags for normalizing a profile.
* Handles:
* - Adding pettingLevel: 0 if missing
* - Migrating onboarding_done → blobbi_onboarding_done
*
* Preserves all existing tags except the ones being migrated.
*/
export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] {
if (!profileNeedsPettingLevelNormalization(profile)) {
return profile.allTags;
let tags = profile.allTags;
let changed = false;
// Normalize pettingLevel
if (profileNeedsPettingLevelNormalization(profile)) {
tags = updateBlobbonautTags(tags, { pettingLevel: '0' });
changed = true;
}
return updateBlobbonautTags(profile.allTags, {
pettingLevel: '0',
});
// Migrate onboarding_done → blobbi_onboarding_done
if (profileNeedsOnboardingTagMigration(profile)) {
const oldValue = tags.find(([name]) => name === 'onboarding_done')?.[1] ?? 'false';
// Remove old tag, add new tag
tags = tags.filter(([name]) => name !== 'onboarding_done');
tags = updateBlobbonautTags(tags, { blobbi_onboarding_done: oldValue });
changed = true;
}
return changed ? tags : profile.allTags;
}
// ─── Query Helpers ────────────────────────────────────────────────────────────
+91 -2
View File
@@ -9,7 +9,7 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
@@ -27,6 +27,18 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
// ─── Types ────────────────────────────────────────────────────────────────────
/** Tour dev actions for the first-hatch tour */
interface FirstHatchTourDevActions {
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
skipPostRequirement: () => void;
/** Reset the entire first-hatch tour so it can be tested again from scratch */
resetTour: () => void;
/** Current tour step id, or null if not active */
currentStepId: string | null;
/** Whether the tour has been completed */
isCompleted: boolean;
}
interface BlobbiDevEditorProps {
/** Whether the editor modal is open */
isOpen: boolean;
@@ -38,6 +50,8 @@ interface BlobbiDevEditorProps {
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
/** Whether an update is in progress */
isUpdating?: boolean;
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
tourDevActions?: FirstHatchTourDevActions;
}
/** Updates that can be applied to a Blobbi */
@@ -170,6 +184,7 @@ export function BlobbiDevEditor({
companion,
onApply,
isUpdating = false,
tourDevActions,
}: BlobbiDevEditorProps) {
// ─── Local State ───
// Initialize from companion values
@@ -527,8 +542,82 @@ export function BlobbiDevEditor({
onCheckedChange={setBreedingReady}
/>
</div>
</div>
</div>
</div>
{/* ─── First-Hatch Tour Controls ─── */}
{tourDevActions && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
<Badge variant="outline" className="text-xs">
{tourDevActions.isCompleted
? 'Completed'
: tourDevActions.currentStepId
? tourDevActions.currentStepId
: 'Not started'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Test the first-hatch tour flow without needing to create a real post.
</p>
<div className="flex flex-wrap gap-2">
{/* A. Skip Post Requirement */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.skipPostRequirement();
}}
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
className="gap-2 text-xs"
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
>
<SkipForward className="size-3.5" />
Skip Post
</Button>
{/* B. Restart First-Hatch Tour */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.resetTour();
}}
className="gap-2 text-xs"
title="Reset the entire first-hatch tour state so it can be tested again"
>
<RefreshCw className="size-3.5" />
Restart Tour
</Button>
{/* C. Reset Blobbi to Egg */}
<Button
variant="outline"
size="sm"
onClick={() => {
setStage('egg');
setState('active');
tourDevActions.resetTour();
}}
disabled={companion.stage === 'egg'}
className="gap-2 text-xs"
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
>
<Egg className="size-3.5" />
Reset to Egg + Tour
</Button>
</div>
{companion.stage !== 'egg' && stage === 'egg' && (
<p className="text-xs text-amber-500">
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
</p>
)}
</div>
</>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
+283 -140
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { EggVisualBlobbi } from '../types/egg.types';
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
@@ -25,6 +25,29 @@ export interface EggStatusEffects {
happy?: boolean;
}
/**
* Tour visual states that the egg can display.
* Driven by the tour orchestration layer, not by EggGraphic itself.
*
* - idle: no tour effects
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
* - crack_stage_1: crack expands (click 1)
* - crack_stage_2: crack expands more (click 2)
* - crack_stage_3: final crack (click 3)
* - opening: shell splits open
* - hatching: bright light + reveal
*/
export type EggTourVisualState =
| 'idle'
| 'show_hatch_card'
| 'glowing_waiting_click'
| 'crack_stage_1'
| 'crack_stage_2'
| 'crack_stage_3'
| 'opening'
| 'hatching';
interface EggGraphicProps {
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
@@ -36,6 +59,10 @@ interface EggGraphicProps {
forceInlineSvg?: boolean; // New prop to guarantee inline SVG
/** Status effects for egg-stage visual feedback */
statusEffects?: EggStatusEffects;
/** Tour visual state - driven externally by the tour orchestration layer */
tourVisualState?: EggTourVisualState;
/** Callback when the egg is clicked during an interactive tour step */
onTourEggClick?: () => void;
}
/**
@@ -114,6 +141,8 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
warmth = 50,
forceInlineSvg: _forceInlineSvg = false,
statusEffects,
tourVisualState = 'idle',
onTourEggClick,
}) => {
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
// Parent container controls actual rendered width/height via slot
@@ -152,14 +181,62 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
const [isTapWiggling, setIsTapWiggling] = useState(false);
const handleEggClick = useCallback(() => {
// Tour interactive steps: forward click to tour controller
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
setIsTapWiggling(true);
onTourEggClick();
return;
}
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
setIsTapWiggling(true);
}, [isTapWiggling, cracking]);
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
const handleWiggleEnd = useCallback(() => {
setIsTapWiggling(false);
}, []);
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!shouldAutoWiggle) {
if (autoWiggleTimerRef.current) {
clearInterval(autoWiggleTimerRef.current);
autoWiggleTimerRef.current = null;
}
return;
}
// Trigger an immediate wiggle, then repeat every 2.5s
setIsTapWiggling(true);
autoWiggleTimerRef.current = setInterval(() => {
setIsTapWiggling((prev) => {
if (!prev) return true;
return prev;
});
}, 2500);
return () => {
if (autoWiggleTimerRef.current) {
clearInterval(autoWiggleTimerRef.current);
autoWiggleTimerRef.current = null;
}
};
}, [shouldAutoWiggle]);
// Tour: whether the egg should show crack overlay
// The crack stays visible during 'opening' so the shell fades out WITH its cracks intact.
// Only 'idle' and 'hatching' (shell already gone) hide the crack.
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
// Level 1: crack expands left/right with small branches (crack_stage_1)
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
const tourCrackLevel = tourVisualState === 'crack_stage_1' ? 1
: tourVisualState === 'crack_stage_2' ? 2
: (tourVisualState === 'crack_stage_3' || tourVisualState === 'opening') ? 3
: 0;
// Divine color constants
const DIVINE_PRIMARY_GREEN = '#55C4A2';
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
@@ -440,18 +517,32 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
}}
>
{/* Glow effect based on warmth - relative sizing */}
<div
className={cn(
'absolute rounded-full blur-xl transition-all duration-1000',
animated && 'animate-pulse'
)}
style={{
width: '120%',
height: '120%',
background: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
zIndex: 0,
}}
/>
{(() => {
const isGlowingTour = tourVisualState === 'glowing_waiting_click'
|| tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2'
|| tourVisualState === 'crack_stage_3';
const isHatchLight = tourVisualState === 'opening' || tourVisualState === 'hatching';
return (
<div
className={cn(
'absolute rounded-full blur-xl transition-all duration-1000',
animated && !isGlowingTour && !isHatchLight && 'animate-pulse',
isGlowingTour && 'animate-egg-tour-glow',
isHatchLight && 'animate-egg-tour-glow',
)}
style={{
width: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
height: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
background: isHatchLight
? `radial-gradient(circle, #fff 0%, ${glowColor} 40%, transparent 70%)`
: isGlowingTour
? `radial-gradient(circle, ${glowColor} 0%, ${glowColor}80 30%, transparent 70%)`
: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
zIndex: 0,
}}
/>
);
})()}
{/* Main egg shape - uses percentage-based sizing */}
<div
@@ -468,8 +559,12 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
!isTapWiggling && reaction === 'singing' && 'animate-egg-bounce',
// Warmth effect only when animated AND warm
animated && actualWarmth > 60 && 'animate-egg-warmth',
// Cracking overrides other animations
cracking && 'animate-egg-crack'
// Cracking overrides other animations (legacy prop or tour crack stages)
// During 'opening' the shell runs its own open animation, so suppress the shake
(cracking || (tourCrackLevel >= 1 && tourVisualState !== 'opening')) && 'animate-egg-crack',
// Opening/hatching: fade out the egg shell (crack overlay stays inside and fades with it)
tourVisualState === 'opening' && 'animate-egg-tour-open',
tourVisualState === 'hatching' && 'opacity-0',
)}
style={{
width: '80%',
@@ -480,7 +575,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
inset -0.5em -0.5em 1em ${shadow}33,
inset 0.5em 0.5em 1em ${highlight}26
`,
filter: cracking ? 'brightness(1.1)' : 'brightness(1)',
filter: (cracking || tourCrackLevel >= 1) ? 'brightness(1.1)' : 'brightness(1)',
}}
>
{/* Highlight on the egg - uses color variants instead of white */}
@@ -538,133 +633,181 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
renderLegacySpecialMark(effectiveSpecialMark)
))}
{/* Crack pattern based on docs/aprovado.svg when cracking is true */}
{cracking && (
<svg
className="absolute inset-0 pointer-events-none w-full h-full"
viewBox="0 0 120 125"
preserveAspectRatio="xMidYMid meet"
style={{
height: '100%',
}}
>
{/* Main horizontal crack (adapted from aprovado.svg) */}
<path
d="M10 62
L20 60
L30 64
L40 59
L50 65
L60 58
L70 66
L80 57
L90 67
L100 59
L110 65"
stroke="rgba(0, 0, 0, 0.6)"
strokeWidth="2"
fill="none"
strokeLinecap="round"
/>
{/* Crack pattern - stage-specific paths that grow outward from center */}
{(cracking || tourShowCrack) && (() => {
// Legacy cracking shows full crack; tour uses progressive stage-specific paths
const level = cracking ? 3 : tourCrackLevel;
return (
<svg
className="absolute inset-0 pointer-events-none w-full h-full transition-opacity duration-300"
viewBox="0 0 120 125"
preserveAspectRatio="xMidYMid meet"
style={{ height: '100%' }}
>
{/*
Stage-specific crack paths.
Each level has its OWN distinct paths that expand outward from the egg center.
The crack grows from a small central cluster to full-width fracture.
{/* Secondary cracks (adapted from aprovado.svg) */}
<path
d="M30 64 L28 70"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M50 65 L53 71"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M60 58 L57 52"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M80 57 L82 50"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M90 67 L95 72"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M100 59 L97 53"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M110 65 L113 69"
stroke="rgba(0, 0, 0, 0.4)"
strokeWidth="1"
strokeLinecap="round"
/>
Viewbox center is roughly (60, 62).
Level 0: tiny central crack (~3-4 small connected segments near center)
Level 1: extends left/right from center, first branches
Level 2: reaches further toward edges, more fracture detail
Level 3: crack reaches near shell edges, dense branching
*/}
{/* Additional micro-cracks for detail */}
<path
d="M40 59 L38 55"
stroke="rgba(0, 0, 0, 0.25)"
strokeWidth="0.8"
strokeLinecap="round"
/>
<path
d="M70 66 L73 70"
stroke="rgba(0, 0, 0, 0.25)"
strokeWidth="0.8"
strokeLinecap="round"
/>
<path
d="M20 60 L18 56"
stroke="rgba(0, 0, 0, 0.2)"
strokeWidth="0.6"
strokeLinecap="round"
/>
{/* ── Level 0: Small central crack ── */}
{/* A few short connected segments clustered around the center of the egg */}
{level === 0 && (<>
{/* Main tiny crack: ~15px wide, centered */}
<path
d="M53 63 L57 60 L63 64 L67 61"
stroke="rgba(0, 0, 0, 0.5)"
strokeWidth="1.2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Tiny upward branch from center */}
<path
d="M57 60 L56 57"
stroke="rgba(0, 0, 0, 0.35)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Tiny downward branch */}
<path
d="M63 64 L65 67"
stroke="rgba(0, 0, 0, 0.35)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Subtle highlight alongside main crack */}
<path
d="M54 64 L58 61 L64 65"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.6"
fill="none"
strokeLinecap="round"
/>
</>)}
{/* Crack highlights for depth (following the main crack pattern) */}
<path
d="M10 63
L20 61
L30 65
L40 60
L50 66
L60 59
L70 67
L80 58
L90 68
L100 60
L110 66"
stroke="rgba(255, 255, 255, 0.15)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* ── Level 1: Medium crack expanding from center ── */}
{/* Crack extends ~30px wide, first real branches appear */}
{level === 1 && (<>
{/* Main crack: wider than level 0, extends left and right */}
<path
d="M42 61 L48 64 L53 60 L60 65 L67 59 L73 63 L78 60"
stroke="rgba(0, 0, 0, 0.55)"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M43 62 L49 65 L54 61 L61 66 L68 60 L74 64"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.6"
fill="none"
strokeLinecap="round"
/>
{/* Branch: upward left */}
<path d="M48 64 L46 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branch: upward from center-right */}
<path d="M67 59 L65 54" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branch: downward right */}
<path d="M73 63 L76 68" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
{/* Small micro-branch */}
<path d="M53 60 L51 56" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.7" strokeLinecap="round" />
</>)}
{/* Secondary crack highlights */}
<path
d="M30 65 L28 71"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth="0.4"
strokeLinecap="round"
/>
<path
d="M60 59 L57 53"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth="0.4"
strokeLinecap="round"
/>
</svg>
)}
{/* ── Level 2: Larger crack reaching toward sides ── */}
{/* Crack extends ~60px wide, more branching detail */}
{level === 2 && (<>
{/* Main crack: extends well toward both sides */}
<path
d="M30 63 L37 60 L44 65 L52 59 L60 64 L68 58 L76 63 L83 59 L90 64"
stroke="rgba(0, 0, 0, 0.6)"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M31 64 L38 61 L45 66 L53 60 L61 65 L69 59 L77 64 L84 60"
stroke="rgba(255, 255, 255, 0.12)"
strokeWidth="0.7"
fill="none"
strokeLinecap="round"
/>
{/* Branches: left side */}
<path d="M37 60 L34 55" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
<path d="M44 65 L41 71" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branches: center */}
<path d="M52 59 L50 53" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
<path d="M60 64 L63 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
{/* Branches: right side */}
<path d="M68 58 L66 52" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
<path d="M76 63 L79 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
<path d="M83 59 L86 54" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
{/* Micro-cracks */}
<path d="M50 53 L48 50" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M63 70 L66 73" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
</>)}
{/* ── Level 3: Full crack reaching shell edges ── */}
{/* Crack spans nearly the full width, dense fracture network */}
{level >= 3 && (<>
{/* Main crack: nearly full width of egg */}
<path
d="M15 62 L23 59 L32 64 L40 58 L50 65 L60 57 L70 64 L80 58 L88 63 L96 59 L105 64"
stroke="rgba(0, 0, 0, 0.65)"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Highlight */}
<path
d="M16 63 L24 60 L33 65 L41 59 L51 66 L61 58 L71 65 L81 59 L89 64 L97 60"
stroke="rgba(255, 255, 255, 0.13)"
strokeWidth="0.8"
fill="none"
strokeLinecap="round"
/>
{/* Heavy branches: left region */}
<path d="M23 59 L19 53" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M32 64 L28 72" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M28 72 L25 76" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.9" strokeLinecap="round" />
{/* Heavy branches: center-left */}
<path d="M40 58 L37 51" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M50 65 L47 73" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M37 51 L35 47" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
{/* Heavy branches: center */}
<path d="M60 57 L58 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M60 57 L63 68" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
{/* Heavy branches: center-right */}
<path d="M70 64 L73 71" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M80 58 L83 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
<path d="M83 50 L86 46" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
{/* Heavy branches: right region */}
<path d="M88 63 L91 70" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M96 59 L99 52" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
<path d="M105 64 L109 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
{/* Micro-cracks (tertiary detail) */}
<path d="M47 73 L44 77" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M73 71 L76 75" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M58 50 L55 46" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
<path d="M19 53 L17 49" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
<path d="M99 52 L102 48" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
</>)}
</svg>
);
})()}
{/* Title display for special eggs */}
{blobbi?.title && (
+1 -1
View File
@@ -12,7 +12,7 @@
import './styles/egg-animations.css';
// Components
export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic';
export { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from './components/EggGraphic';
export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer';
// Hooks
+46 -1
View File
@@ -320,6 +320,49 @@
transform: translateZ(0);
}
/* ==========================================
Tour Visual State Animations
========================================== */
/* Shell opening: scale up slightly then fade out with blur */
@keyframes egg-tour-open {
0% {
transform: scale(1);
opacity: 1;
filter: brightness(1.1);
}
40% {
transform: scale(1.05);
opacity: 0.9;
filter: brightness(1.4);
}
100% {
transform: scale(1.15);
opacity: 0;
filter: brightness(2) blur(4px);
}
}
.animate-egg-tour-open {
animation: egg-tour-open 1.2s ease-in-out forwards;
}
/* Pulsing glow for the "waiting for click" tour state */
@keyframes egg-tour-glow {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
.animate-egg-tour-glow {
animation: egg-tour-glow 2s ease-in-out infinite;
}
/* ==========================================
Responsive adjustments
========================================== */
@@ -351,7 +394,9 @@
.animate-egg-sweat-drop,
.animate-egg-dust-particle,
.animate-egg-spiral,
.animate-egg-sparkle {
.animate-egg-sparkle,
.animate-egg-tour-glow,
.animate-egg-tour-open {
animation: none !important;
}
}
@@ -456,15 +456,18 @@ export function useBlobbiOnboarding({
updateCompanionEvent(eggEvent);
// 2. Update profile: deduct coins, add to has, set current_companion
// 2. Update profile: deduct coins, add to has list
// NOTE: We do NOT set current_companion here because the adopted Blobbi
// is still an egg. The companion mechanic only becomes available after hatching.
// Eggs should never be auto-assigned as the floating companion.
// NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not
// complete onboarding. It is set when the first-hatch tour finishes.
const newCoins = coins - BLOBBI_ADOPTION_COST;
const newHas = [...profile.has, preview.d];
const profileUpdates: Record<string, string | string[]> = {
coins: newCoins.toString(),
has: newHas,
current_companion: preview.d,
onboarding_done: 'true',
};
const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
@@ -0,0 +1,133 @@
/**
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
*
* Rendered directly in the BlobbiPage layout so the experience feels
* focused and guided. Adapts its messaging based on the current tour step.
*
* When the post mission is completed, the card stays visible with a
* celebratory completed state for ~2s (the parent auto-advances after
* that delay). This ensures the user sees the checkmark before the
* flow progresses to the egg-tap phase.
*/
import { Send, Check, MousePointerClick } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { FirstHatchTourStepId } from '../lib/tour-types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourCardProps {
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Current tour step id for adaptive messaging */
currentStep: FirstHatchTourStepId | null;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourCard({
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
currentStep,
}: FirstHatchTourCardProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
// Determine which phase of the card to show
const isPostStep = currentStep === 'show_hatch_card';
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|| currentStep === 'egg_crack_stage_1'
|| currentStep === 'egg_crack_stage_2'
|| currentStep === 'egg_crack_stage_3';
return (
<div className="w-full max-w-sm mx-auto space-y-4">
{/* Title + description */}
<div className="text-center space-y-1.5">
<h3 className="text-lg font-semibold">
{isClickStep
? `Tap ${capitalizedName} to hatch!`
: postCompleted && isPostStep
? `${capitalizedName} heard you!`
: `${capitalizedName} is ready to hatch!`}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{isClickStep
? `Tap the egg to help ${capitalizedName} break free.`
: postCompleted && isPostStep
? 'Your post was shared. Get ready to hatch...'
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
</p>
</div>
{/* Mission card - only during post step */}
{isPostStep && (
<div className="rounded-xl border bg-card p-4 space-y-3">
{postCompleted ? (
/* ── Completed state — celebratory, stays visible ── */
<div className="flex flex-col items-center gap-2 py-2">
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
<Check className="size-5 text-emerald-500" />
</div>
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
Post shared!
</p>
<p className="text-xs text-muted-foreground">
Continuing in a moment...
</p>
</div>
) : (
/* ── Pending state — post mission ── */
<>
<div className="flex items-start gap-3">
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium">Share a hatch post</p>
<p className="text-xs text-muted-foreground">
Your post must include:
</p>
<p className="text-xs font-medium text-primary break-words">
{requiredPhrase}
</p>
</div>
</div>
<Button
size="sm"
className="w-full"
onClick={onCreatePost}
>
<Send className="size-3.5 mr-2" />
Create Post
</Button>
</>
)}
</div>
)}
{/* Tap hint during click steps */}
{isClickStep && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<MousePointerClick className="size-4" />
<span>Tap the egg</span>
</div>
)}
{/* Extra hint for post step */}
{isPostStep && !postCompleted && (
<p className="text-xs text-center text-muted-foreground">
You can add extra text before or after the required phrase.
</p>
)}
</div>
);
}
@@ -0,0 +1,119 @@
/**
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
*
* Tells the user their egg is about to hatch and guides them to create a post.
* Contains a single mission: create the hatch post.
*/
import { Egg, Send, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Advance the tour (called after post is confirmed complete) */
onContinue: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourModal({
open,
onOpenChange,
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
onContinue,
}: FirstHatchTourModalProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
{/* Header with egg accent */}
<div className="px-6 pt-8 pb-4 text-center space-y-3">
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
<Egg className="size-7 text-amber-500" />
</div>
<DialogTitle className="text-xl font-bold">
{capitalizedName} is ready to hatch!
</DialogTitle>
<p className="text-sm text-muted-foreground leading-relaxed">
Share a post to the Nostr network and help {capitalizedName} break free.
</p>
</div>
{/* Mission card */}
<div className="px-6 pb-4">
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-start gap-3">
{/* Status indicator */}
<div className={
postCompleted
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
}>
{postCompleted && <Check className="size-3 text-emerald-500" />}
</div>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium">
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
</p>
<p className="text-xs text-muted-foreground">
Post must include the phrase:
</p>
<p className="text-xs font-medium text-primary break-words">
{requiredPhrase}
</p>
</div>
</div>
{!postCompleted && (
<Button
size="sm"
className="w-full"
onClick={onCreatePost}
>
<Send className="size-3.5 mr-2" />
Create Post
</Button>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 pb-6">
{postCompleted ? (
<Button className="w-full" onClick={onContinue}>
Continue
</Button>
) : (
<p className="text-xs text-center text-muted-foreground">
You can add extra text before or after the required phrase.
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
+226
View File
@@ -0,0 +1,226 @@
/**
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
*
* Orchestration only -- no rendering, no animations.
* The hook manages:
* - Ordered step progression
* - Persisted state via localStorage (survives refresh / close)
* - Derived booleans for UI consumption
* - Safe advance / goTo / complete / reset actions
*
* Activation is handled separately by useFirstHatchTourActivation,
* which calls `start()` when all preconditions are met.
*
* ────────────────────────────────────────────────────────────────
* Future integration points
* ────────────────────────────────────────────────────────────────
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
* to decide whether to start the tour.
* 2. UI components read `state.currentStepId` and render overlays,
* spotlights, modals, or animation cues accordingly.
* 3. Animation components call `actions.advance()` when their
* sequence finishes (for autoAdvance steps).
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
* on the user interaction.
* 5. EggGraphic receives a visual-state prop derived from
* `state.currentStepId` -- it does NOT own the tour logic.
*/
import { useMemo, useCallback, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
type FirstHatchTourStepId,
type FirstHatchTourPersistedState,
type TourState,
type TourActions,
} from '../lib/tour-types';
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* localStorage key for the first hatch tour state.
* Not user-scoped because onboarding state is device-local and the tour
* is inherently tied to "first ever egg on this device". If multi-user
* support on the same device becomes a concern, scope by pubkey.
*/
const STORAGE_KEY = 'blobbi:tour:first-hatch';
/** Pre-computed lookup: stepId -> index */
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
);
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
// ─── Result Type ──────────────────────────────────────────────────────────────
export interface UseFirstHatchTourResult {
/** Reactive tour state for UI consumption */
state: TourState<FirstHatchTourStepId>;
/** Actions to drive the tour forward */
actions: TourActions<FirstHatchTourStepId>;
/**
* Convenience: check if the current step matches a given id.
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
*/
isStep: (stepId: FirstHatchTourStepId) => boolean;
/**
* Convenience: check if the current step is one of the given ids.
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
*/
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
/**
* The current step definition (with autoAdvance metadata), or null.
*/
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useFirstHatchTour(): UseFirstHatchTourResult {
// ── Persisted state ──
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
STORAGE_KEY,
FIRST_HATCH_TOUR_DEFAULT_STATE,
);
// Stable ref to current persisted state so callbacks never go stale.
const persistedRef = useRef(persisted);
persistedRef.current = persisted;
// ── Helpers ──
const updatePersisted = useCallback(
(patch: Partial<FirstHatchTourPersistedState>) => {
setPersisted((prev) => ({
...prev,
...patch,
updatedAt: Date.now(),
}));
},
[setPersisted],
);
// ── Actions ──
const start = useCallback(() => {
const p = persistedRef.current;
// No-op if already active or completed
if (p.completed || p.currentStepId !== null) return;
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
if (!firstStep) return;
updatePersisted({ currentStepId: firstStep.id });
}, [updatePersisted]);
const advance = useCallback(() => {
const p = persistedRef.current;
if (p.completed || p.currentStepId === null) return;
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
if (currentIndex === undefined) return;
const nextIndex = currentIndex + 1;
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
// Past the end -- complete
updatePersisted({ currentStepId: null, completed: true });
return;
}
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
if (nextStep.id === 'complete') {
// Reaching the 'complete' terminal step means the tour is done
updatePersisted({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: nextStep.id });
}
}, [updatePersisted]);
const goTo = useCallback(
(stepId: FirstHatchTourStepId) => {
if (!STEP_INDEX_MAP.has(stepId)) {
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
}
if (stepId === 'complete') {
updatePersisted({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: stepId, completed: false });
}
},
[updatePersisted],
);
const complete = useCallback(() => {
updatePersisted({ currentStepId: null, completed: true });
}, [updatePersisted]);
const reset = useCallback(() => {
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
}, [setPersisted]);
// ── Derived state ──
const currentStepIndex = persisted.currentStepId !== null
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
: -1;
const state = useMemo((): TourState<FirstHatchTourStepId> => {
const isActive = persisted.currentStepId !== null && !persisted.completed;
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
return {
isActive,
currentStepId: persisted.currentStepId,
currentStepIndex,
totalSteps,
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
isCompleted: persisted.completed,
progress: persisted.completed
? 1
: currentStepIndex >= 0
? currentStepIndex / LAST_REAL_STEP_INDEX
: 0,
};
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
start,
advance,
goTo,
complete,
reset,
}), [start, advance, goTo, complete, reset]);
// ── Convenience helpers ──
const isStep = useCallback(
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
[persisted.currentStepId],
);
const isAnyStep = useCallback(
(...stepIds: FirstHatchTourStepId[]) => {
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
},
[persisted.currentStepId],
);
const currentStepDef = currentStepIndex >= 0
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
: null;
return {
state,
actions,
isStep,
isAnyStep,
currentStepDef,
};
}
@@ -0,0 +1,164 @@
/**
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
*
* This hook checks all preconditions and calls `tour.actions.start()` when
* the tour should activate. It is intentionally separated from the tour
* state machine so that:
* - The state machine stays generic and reusable.
* - Activation rules are centralized in one place.
* - The rules are easy to read and modify.
*
* ────────────────────────────────────────────────────────────────
* Activation rules (ALL must be true):
* ────────────────────────────────────────────────────────────────
* 1. The companions list is loaded (not loading / error).
* 2. The user has exactly 1 Blobbi.
* 3. That Blobbi is in the egg stage.
* 4. No Blobbi is in baby or adult stage.
* 5. The tour has not been completed yet (checked via profile tag
* AND localStorage fallback).
*
* Completion is authoritative from the Blobbonaut profile event
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
* is a secondary signal for in-progress UI state and as a fallback
* when the profile hasn't been updated yet.
* ────────────────────────────────────────────────────────────────
*/
import { useEffect, useMemo } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface FirstHatchTourActivationInput {
/** The full list of the user's Blobbi companions */
companions: BlobbiCompanion[];
/** Whether the companions list is still loading */
isLoading: boolean;
/** The tour hook result (localStorage-based state machine) */
tour: UseFirstHatchTourResult;
/**
* Whether onboarding is already marked complete in the Blobbonaut profile
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
* When true, the tour will not activate regardless of localStorage state.
*/
profileOnboardingDone?: boolean;
}
export interface FirstHatchTourActivationResult {
/**
* Whether all preconditions for activating the tour are met right now.
* This is a derived boolean -- it does NOT mean the tour IS active,
* just that it SHOULD be activated. The tour may already be active
* from a previous render or a persisted state.
*/
shouldActivate: boolean;
/**
* Whether the tour is eligible (preconditions met and not yet completed).
* Useful for hiding UI that should only appear during the tour window.
*/
isEligible: boolean;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Evaluates activation preconditions and auto-starts the tour when met.
*
* Usage:
* ```ts
* const tour = useFirstHatchTour();
* const activation = useFirstHatchTourActivation({
* companions,
* isLoading: companionsLoading,
* tour,
* profileOnboardingDone: profile?.onboardingDone,
* });
* ```
*/
export function useFirstHatchTourActivation({
companions,
isLoading,
tour,
profileOnboardingDone: _profileOnboardingDone = false,
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
// ── Precondition evaluation ──
const { shouldActivate, isEligible } = useMemo(() => {
// Can't evaluate until data is loaded
if (isLoading) {
return { shouldActivate: false, isEligible: false };
}
// localStorage tour already completed — this is always authoritative
if (tour.state.isCompleted) {
return { shouldActivate: false, isEligible: false };
}
// Must have exactly 1 companion
if (companions.length !== 1) {
return { shouldActivate: false, isEligible: false };
}
const onlyBlobbi = companions[0];
// That companion must be an egg
if (onlyBlobbi.stage !== 'egg') {
return { shouldActivate: false, isEligible: false };
}
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
// but kept explicit for clarity and future-proofing if rules change)
const hasBabyOrAdult = companions.some(
(c) => c.stage === 'baby' || c.stage === 'adult',
);
if (hasBabyOrAdult) {
return { shouldActivate: false, isEligible: false };
}
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
// Some older accounts had `onboarding_done` migrated to
// `blobbi_onboarding_done=true` before the first-hatch tour
// existed, so they never experienced it. When the user is in the
// exact single-egg/no-evolved-companions state (all checks above
// passed), we intentionally ignore `profileOnboardingDone` so
// those accounts can still enter the tour.
//
// This is safe because:
// - The localStorage `tour.state.isCompleted` check above
// already prevents re-triggering for users who HAVE finished
// the tour.
// - The egg-stage + single-companion guard means this only
// fires for users who genuinely haven't hatched yet.
//
// TODO: Replace `blobbi_onboarding_done` with a dedicated
// `blobbi_first_hatch_tour_done` tag so onboarding completion
// and tour completion are tracked independently. Once that tag
// is in place, remove this safeguard and gate activation on the
// new tag instead.
// ───────────────────────────────────────────────────────────────
// (profileOnboardingDone is intentionally NOT checked here)
// All preconditions met
const eligible = true;
// Only activate if the tour is not already running
const activate = !tour.state.isActive;
return { shouldActivate: activate, isEligible: eligible };
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
// ── Auto-start effect ──
// When all preconditions are met and the tour hasn't started yet,
// start it. This fires once and then `shouldActivate` flips to false
// because `tour.state.isActive` becomes true.
useEffect(() => {
if (shouldActivate) {
tour.actions.start();
}
}, [shouldActivate, tour.actions]);
return { shouldActivate, isEligible };
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Blobbi Tour Module
*
* Provides the orchestration layer for guided tours / tutorials.
* Currently implements the first-egg hatch tour.
*
* Architecture:
* - tour-types.ts: Step definitions, persisted state shape, generic types
* - useFirstHatchTour: State machine (step progression, persistence, actions)
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
*
* UI components import from this barrel and read tour state to decide
* what to render. They call tour actions (advance, goTo, complete) in
* response to user interactions or animation completions.
*/
// ── Types (generic tour infrastructure) ──
export type {
TourStepDef,
TourPersistedState,
TourState,
TourActions,
} from './lib/tour-types';
// ── First Hatch Tour - Types & Constants ──
export {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
} from './lib/tour-types';
export type {
FirstHatchTourStepId,
FirstHatchTourPersistedState,
} from './lib/tour-types';
// ── First Hatch Tour - Hooks ──
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
export type {
FirstHatchTourActivationInput,
FirstHatchTourActivationResult,
} from './hooks/useFirstHatchTourActivation';
// ── First Hatch Tour - Components ──
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
+140
View File
@@ -0,0 +1,140 @@
/**
* Tour System - Core Types
*
* Generic, reusable types for step-based guided tours.
* The tour system is designed to be:
* - Easy to extend with new tours (define steps + config)
* - Easy to reorder steps (change the STEPS array)
* - Persistent across page refreshes (localStorage)
* - Decoupled from rendering (UI reads state, doesn't own it)
*/
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
/**
* A tour step definition.
*
* Each step has a unique id and optional metadata that future UI layers
* can use to decide what to render (spotlights, modals, animations, etc.).
*/
export interface TourStepDef<StepId extends string = string> {
/** Unique identifier for this step */
id: StepId;
/**
* Whether this step auto-advances (e.g. animations) or waits for
* an explicit `advance()` / `goTo()` call from the UI.
* Default: false (manual).
*/
autoAdvance?: boolean;
}
/**
* Persisted state for a tour.
* Stored in localStorage so tours survive refresh / close / return.
*/
export interface TourPersistedState<StepId extends string = string> {
/** Current step id, or null when the tour is not yet started */
currentStepId: StepId | null;
/** Whether the tour has been completed */
completed: boolean;
/** Unix ms timestamp of last state change (for debugging / analytics) */
updatedAt: number;
}
/**
* Full runtime state exposed by a tour hook.
*/
export interface TourState<StepId extends string = string> {
/** Whether the tour is currently active (started and not yet completed) */
isActive: boolean;
/** Current step id, or null when idle / completed */
currentStepId: StepId | null;
/** 0-based index of the current step in the steps array, or -1 */
currentStepIndex: number;
/** Total number of steps */
totalSteps: number;
/** Whether the current step is the last one before completion */
isLastStep: boolean;
/** Whether the tour has been completed (persisted) */
isCompleted: boolean;
/** Progress as a fraction 0..1 */
progress: number;
}
/**
* Actions exposed by a tour hook.
*/
export interface TourActions<StepId extends string = string> {
/** Start the tour from the first step (no-op if already active or completed) */
start: () => void;
/** Advance to the next step. Completes the tour if on the last step. */
advance: () => void;
/** Jump to a specific step by id. Throws if the step doesn't exist. */
goTo: (stepId: StepId) => void;
/** Mark the tour as completed and reset to idle. */
complete: () => void;
/** Reset the tour entirely (clears persisted state). For dev/testing. */
reset: () => void;
}
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
/**
* Step ids for the first-egg hatch tour.
*
* Flow:
* 1. idle — initial state (auto-advances immediately)
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
* 4. egg_crack_stage_1 — click 1: crack expands
* 5. egg_crack_stage_2 — click 2: crack expands further
* 6. egg_crack_stage_3 — click 3: crack reaches edges
* 7. egg_opening — shell opens (auto-advance after animation)
* 8. egg_hatching — bright light + baby reveal (auto-advance)
* 9. complete — terminal, marks tour done
*
* The order here matches the intended flow. To reorder steps,
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
*/
export type FirstHatchTourStepId =
| 'idle'
| 'show_hatch_card'
| 'egg_glowing_waiting_click'
| 'egg_crack_stage_1'
| 'egg_crack_stage_2'
| 'egg_crack_stage_3'
| 'egg_opening'
| 'egg_hatching'
| 'complete';
/**
* Ordered step definitions for the first hatch tour.
*
* To add / remove / reorder steps, edit this array.
* The tour state machine walks through these in order.
*/
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
{ id: 'idle' },
{ id: 'show_hatch_card' },
{ id: 'egg_glowing_waiting_click' },
{ id: 'egg_crack_stage_1' },
{ id: 'egg_crack_stage_2' },
{ id: 'egg_crack_stage_3' },
{ id: 'egg_opening', autoAdvance: true },
{ id: 'egg_hatching', autoAdvance: true },
{ id: 'complete' },
];
/**
* Persisted state shape for the first hatch tour.
*/
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
/**
* Default persisted state for a brand-new first hatch tour.
*/
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
currentStepId: null,
completed: false,
updatedAt: 0,
};
+10 -2
View File
@@ -13,7 +13,7 @@
import { useMemo } from 'react';
import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg';
import { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from '@/blobbi/egg';
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
@@ -23,7 +23,7 @@ import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
export type BlobbiEggSize = 'sm' | 'md' | 'lg';
// Re-export for convenience
export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg';
export type { EggReactionState, EggStatusEffects, EggTourVisualState } from '@/blobbi/egg';
export interface BlobbiEggVisualProps {
/** The Blobbi companion data from parseBlobbiEvent */
@@ -36,6 +36,10 @@ export interface BlobbiEggVisualProps {
reaction?: EggReactionState;
/** Status effects for egg visual feedback (dirty, sick, happy) */
statusEffects?: EggStatusEffects;
/** Tour visual state - driven externally by the tour orchestration layer */
tourVisualState?: EggTourVisualState;
/** Callback when the egg is clicked during an interactive tour step */
onTourEggClick?: () => void;
/** Additional CSS classes for the container */
className?: string;
}
@@ -70,6 +74,8 @@ export function BlobbiEggVisual({
animated = false,
reaction = 'idle',
statusEffects,
tourVisualState,
onTourEggClick,
className,
}: BlobbiEggVisualProps) {
// Memoize adapter output to avoid unnecessary re-renders
@@ -103,6 +109,8 @@ export function BlobbiEggVisual({
animated={animated && !isSleeping}
reaction={effectiveReaction}
statusEffects={isSleeping ? undefined : statusEffects}
tourVisualState={tourVisualState}
onTourEggClick={onTourEggClick}
/>
</div>
);
+9 -1
View File
@@ -12,7 +12,7 @@
import { useMemo } from 'react';
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects } from './BlobbiEggVisual';
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects, type EggTourVisualState } from './BlobbiEggVisual';
import { BlobbiBabyVisual } from './BlobbiBabyVisual';
import { BlobbiAdultVisual } from './BlobbiAdultVisual';
import { FloatingMusicNotes } from './FloatingMusicNotes';
@@ -50,6 +50,10 @@ export interface BlobbiStageVisualProps {
* Status-reaction body effects are already in the recipe.
*/
bodyEffects?: BodyEffectsSpec;
/** Tour visual state for egg stage - driven by the tour orchestration layer */
tourVisualState?: EggTourVisualState;
/** Callback when the egg is clicked during an interactive tour step */
onTourEggClick?: () => void;
className?: string;
}
@@ -74,6 +78,8 @@ export function BlobbiStageVisual({
recipeLabel,
emotion = 'neutral',
bodyEffects,
tourVisualState,
onTourEggClick,
className,
}: BlobbiStageVisualProps) {
const { stage } = companion;
@@ -109,6 +115,8 @@ export function BlobbiStageVisual({
animated={animated}
reaction={effectiveReaction}
statusEffects={eggStatusEffects}
tourVisualState={tourVisualState}
onTourEggClick={onTourEggClick}
className="size-full"
/>
<FloatingMusicNotes active={showMusicNotes} />
@@ -0,0 +1,208 @@
/**
* ActionBarEditor - Lightweight modal for customizing the bottom action bar.
*
* Rules:
* - Main Action + More are fixed (always shown, not editable)
* - Up to 3 custom visible slots
* - User can toggle visibility, reorder, and highlight one item
*/
import { useCallback } from 'react';
import {
ChevronUp,
ChevronDown,
Eye,
EyeOff,
Star,
Egg,
Target,
Package,
Camera,
Footprints,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import {
type ActionBarPreferences,
type BarItemId,
BAR_ITEM_LABELS,
MAX_VISIBLE_SLOTS,
toggleSlotVisibility,
toggleSlotHighlight,
moveSlotUp,
moveSlotDown,
visibleCount,
DEFAULT_PREFERENCES,
} from '../lib/action-bar-preferences';
// ─── Icon Mapping ─────────────────────────────────────────────────────────────
const BAR_ITEM_ICONS: Record<BarItemId, React.ReactNode> = {
blobbies: <Egg className="size-4" />,
missions: <Target className="size-4" />,
items: <Package className="size-4" />,
take_photo: <Camera className="size-4" />,
set_companion: <Footprints className="size-4" />,
};
// ─── Props ────────────────────────────────────────────────────────────────────
interface ActionBarEditorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
preferences: ActionBarPreferences;
onUpdate: (prefs: ActionBarPreferences) => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ActionBarEditor({
open,
onOpenChange,
preferences,
onUpdate,
}: ActionBarEditorProps) {
const currentVisible = visibleCount(preferences);
const atMax = currentVisible >= MAX_VISIBLE_SLOTS;
const handleToggle = useCallback(
(id: BarItemId) => onUpdate(toggleSlotVisibility(preferences, id)),
[preferences, onUpdate],
);
const handleHighlight = useCallback(
(id: BarItemId) => onUpdate(toggleSlotHighlight(preferences, id)),
[preferences, onUpdate],
);
const handleUp = useCallback(
(id: BarItemId) => onUpdate(moveSlotUp(preferences, id)),
[preferences, onUpdate],
);
const handleDown = useCallback(
(id: BarItemId) => onUpdate(moveSlotDown(preferences, id)),
[preferences, onUpdate],
);
const handleReset = useCallback(
() => onUpdate(DEFAULT_PREFERENCES),
[onUpdate],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle className="text-base">Edit Action Bar</DialogTitle>
<DialogDescription className="text-xs">
Choose up to {MAX_VISIBLE_SLOTS} items. Main Action and More are always shown.
</DialogDescription>
</DialogHeader>
<div className="space-y-1 py-2">
{preferences.slots.map((slot, idx) => {
const isFirst = idx === 0;
const isLast = idx === preferences.slots.length - 1;
const canTurnOn = slot.visible || !atMax;
return (
<div
key={slot.id}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 transition-colors',
slot.visible
? 'bg-accent/60'
: 'bg-muted/30 opacity-60',
)}
>
{/* Icon + Label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{BAR_ITEM_ICONS[slot.id]}
<span className="text-sm font-medium truncate">
{BAR_ITEM_LABELS[slot.id]}
</span>
</div>
{/* Highlight toggle */}
{slot.visible && (
<Button
variant="ghost"
size="icon"
className={cn('size-7', slot.highlighted && 'text-amber-500')}
onClick={() => handleHighlight(slot.id)}
title={slot.highlighted ? 'Remove highlight' : 'Highlight'}
>
<Star className={cn('size-3.5', slot.highlighted && 'fill-current')} />
</Button>
)}
{/* Reorder controls */}
<div className="flex flex-col">
<Button
variant="ghost"
size="icon"
className="size-5"
disabled={isFirst}
onClick={() => handleUp(slot.id)}
>
<ChevronUp className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-5"
disabled={isLast}
onClick={() => handleDown(slot.id)}
>
<ChevronDown className="size-3" />
</Button>
</div>
{/* Visibility toggle */}
<Button
variant="ghost"
size="icon"
className="size-7"
disabled={!canTurnOn && !slot.visible}
onClick={() => handleToggle(slot.id)}
title={slot.visible ? 'Hide' : 'Show'}
>
{slot.visible ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</Button>
</div>
);
})}
</div>
{/* Slot counter + reset */}
<div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted-foreground">
{currentVisible}/{MAX_VISIBLE_SLOTS} slots used
</span>
<Button
variant="ghost"
size="sm"
className="text-xs h-7"
onClick={handleReset}
>
Reset to default
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,301 @@
/**
* MissionSurfaceCard - Compact inline card that surfaces ONE relevant
* mission/task at a time below the Blobbi visual.
*
* Priority:
* 1. Hatch / Evolve tasks (lifecycle progression)
* 2. Daily missions (engagement / coin loop)
*
* Carousel:
* - Auto-rotates every ~5s when > 1 card available
* - Manual tap cycles to the next card
* - Auto-advances when the current card's mission completes
* - Single card = no rotation
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Target,
ChevronRight,
Egg,
Sparkles,
Coins,
CircleDot,
X,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks';
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
// ─── Card Item Types ──────────────────────────────────────────────────────────
interface TaskCardItem {
kind: 'task';
badge: 'Hatch' | 'Evolve';
title: string;
description: string;
progress: number; // 0-100
progressLabel: string;
}
interface DailyCardItem {
kind: 'daily';
badge: 'Daily';
title: string;
description: string;
progress: number;
progressLabel: string;
reward: number;
claimed: boolean;
}
type CardItem = TaskCardItem | DailyCardItem;
// ─── Props ────────────────────────────────────────────────────────────────────
interface MissionSurfaceCardProps {
/** Hatch or evolve tasks (from useActiveTaskProcess) */
tasks: HatchTask[];
/** Whether a task process (incubating/evolving) is active */
isInTaskProcess: boolean;
/** Process type for badge label */
processType: 'hatch' | 'evolve' | null;
/** Daily missions */
dailyMissions: DailyMission[];
/** Called when user taps "View all" */
onViewAll: () => void;
/** Called when user dismisses the card */
onHide?: () => void;
/** Additional className */
className?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function buildTaskCards(
tasks: HatchTask[],
processType: 'hatch' | 'evolve' | null,
): TaskCardItem[] {
if (!processType) return [];
const badge = processType === 'hatch' ? 'Hatch' : 'Evolve';
// Show only incomplete tasks, or the first completed one if all are done
const incomplete = tasks.filter((t) => !t.completed);
const toShow = incomplete.length > 0 ? incomplete : tasks.slice(0, 1);
return toShow.map((t) => ({
kind: 'task',
badge: badge as 'Hatch' | 'Evolve',
title: t.name,
description: t.description,
progress: t.required > 0 ? Math.min(100, Math.round((t.current / t.required) * 100)) : 0,
progressLabel: `${t.current}/${t.required}`,
}));
}
function buildDailyCards(missions: DailyMission[]): DailyCardItem[] {
// Show unclaimed missions first, then claimed ones
const unclaimed = missions.filter((m) => !m.claimed);
const toShow = unclaimed.length > 0 ? unclaimed : [];
return toShow.map((m) => ({
kind: 'daily',
badge: 'Daily',
title: m.title,
description: m.description,
progress: m.requiredCount > 0
? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100))
: 0,
progressLabel: `${m.currentCount}/${m.requiredCount}`,
reward: m.reward,
claimed: m.claimed,
}));
}
// ─── Auto-rotate interval ─────────────────────────────────────────────────────
const ROTATE_INTERVAL_MS = 5000;
// ─── Component ────────────────────────────────────────────────────────────────
export function MissionSurfaceCard({
tasks,
isInTaskProcess,
processType,
dailyMissions,
onViewAll,
onHide,
className,
}: MissionSurfaceCardProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
// Build card list: tasks first (priority), then daily
const cards = useMemo<CardItem[]>(() => {
const taskCards = isInTaskProcess ? buildTaskCards(tasks, processType) : [];
const dailyCards = buildDailyCards(dailyMissions);
return [...taskCards, ...dailyCards];
}, [tasks, isInTaskProcess, processType, dailyMissions]);
// Clamp index if cards shrink
useEffect(() => {
if (activeIndex >= cards.length && cards.length > 0) {
setActiveIndex(0);
}
}, [cards.length, activeIndex]);
// Auto-rotate (only when > 1 card)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (cards.length <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
return;
}
timerRef.current = setInterval(() => {
setIsAnimating(true);
setTimeout(() => {
setActiveIndex((prev) => (prev + 1) % cards.length);
setIsAnimating(false);
}, 150);
}, ROTATE_INTERVAL_MS);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [cards.length]);
// Manual cycle
const handleCycle = useCallback(() => {
if (cards.length <= 1) return;
// Reset auto-rotate timer
if (timerRef.current) clearInterval(timerRef.current);
setIsAnimating(true);
setTimeout(() => {
setActiveIndex((prev) => (prev + 1) % cards.length);
setIsAnimating(false);
// Restart timer
timerRef.current = setInterval(() => {
setIsAnimating(true);
setTimeout(() => {
setActiveIndex((prev) => (prev + 1) % cards.length);
setIsAnimating(false);
}, 150);
}, ROTATE_INTERVAL_MS);
}, 150);
}, [cards.length]);
// Nothing to show
if (cards.length === 0) return null;
const card = cards[Math.min(activeIndex, cards.length - 1)];
const badgeColor =
card.badge === 'Hatch'
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
: card.badge === 'Evolve'
? 'bg-purple-500/15 text-purple-600 dark:text-purple-400'
: 'bg-primary/10 text-primary';
const badgeIcon =
card.badge === 'Hatch' ? (
<Egg className="size-3" />
) : card.badge === 'Evolve' ? (
<Sparkles className="size-3" />
) : (
<Target className="size-3" />
);
return (
<div className={cn('w-full', className)}>
<button
onClick={handleCycle}
className={cn(
'w-full text-left rounded-xl border border-border/60 bg-card/80 backdrop-blur-sm',
'px-3.5 py-2.5 transition-all duration-200',
'hover:bg-accent/40 active:scale-[0.99]',
isAnimating && 'opacity-0 translate-x-2',
!isAnimating && 'opacity-100 translate-x-0',
)}
>
{/* Top row: badge + title + view all */}
<div className="flex items-center gap-2 mb-1.5">
<Badge
variant="secondary"
className={cn('text-[10px] font-medium px-1.5 py-0 h-4 gap-1', badgeColor)}
>
{badgeIcon}
{card.badge}
</Badge>
<span className="text-sm font-medium truncate flex-1">
{card.title}
</span>
{/* Dot indicators when multiple cards */}
{cards.length > 1 && (
<div className="flex gap-0.5 items-center shrink-0">
{cards.map((_, i) => (
<CircleDot
key={i}
className={cn(
'size-2 transition-colors',
i === activeIndex
? 'text-primary'
: 'text-muted-foreground/30',
)}
/>
))}
</div>
)}
{/* Dismiss button */}
{onHide && (
<button
onClick={(e) => {
e.stopPropagation();
onHide();
}}
className="shrink-0 p-0.5 -m-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground transition-colors"
title="Hide mission card"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-2 line-clamp-1">
{card.description}
</p>
{/* Bottom row: progress bar + label + reward/view all */}
<div className="flex items-center gap-2">
<Progress
value={card.progress}
className="h-1.5 flex-1"
/>
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
{card.progressLabel}
</span>
{card.kind === 'daily' && !card.claimed && (
<span className="flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium shrink-0">
<Coins className="size-2.5" />
{card.reward}
</span>
)}
</div>
</button>
{/* View all link */}
<button
onClick={onViewAll}
className="flex items-center gap-1 mx-auto mt-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
View all missions
<ChevronRight className="size-3" />
</button>
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* Action Bar Preferences
*
* Lightweight localStorage-backed model controlling which items are
* visible in the BlobbiBottomBar and in which order.
*
* Fixed items (cannot be hidden or reordered by the user):
* - Main Action (center button) -- always present
* - More (right-most button) -- always present
*
* Customizable items (up to 3 visible slots):
* Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion
*
* Persistence: localStorage only for now. Shape is designed so it can
* later migrate to a Nostr event tag.
*/
// ─── Types ────────────────────────────────────────────────────────────────────
/** Identifiers for customizable bottom-bar items */
export type BarItemId =
| 'blobbies'
| 'missions'
| 'items'
| 'take_photo'
| 'set_companion';
/** A single customizable bar slot */
export interface BarItemSlot {
id: BarItemId;
visible: boolean;
/** If true, this item receives a subtle highlight ring in the bar */
highlighted?: boolean;
}
/** Full persisted preference shape */
export interface ActionBarPreferences {
/** Ordered list of customizable items. Visible items render in array order. */
slots: BarItemSlot[];
}
// ─── Constants ────────────────────────────────────────────────────────────────
/** Max visible customizable items (Main Action + More are fixed) */
export const MAX_VISIBLE_SLOTS = 3;
/** localStorage key for bar slot preferences */
export const STORAGE_KEY = 'blobbi:action-bar-prefs';
/** localStorage key for inline mission surface card visibility */
export const MISSION_CARD_STORAGE_KEY = 'blobbi:mission-card-visible';
/** Human-readable labels */
export const BAR_ITEM_LABELS: Record<BarItemId, string> = {
blobbies: 'Blobbies',
missions: 'Missions',
items: 'Items',
take_photo: 'Take Photo',
set_companion: 'Companion',
};
/** Default preferences: only Blobbies visible, others hidden */
export const DEFAULT_PREFERENCES: ActionBarPreferences = {
slots: [
{ id: 'blobbies', visible: true },
{ id: 'missions', visible: false },
{ id: 'items', visible: false },
{ id: 'take_photo', visible: false },
{ id: 'set_companion', visible: false },
],
};
// ─── Utilities ────────────────────────────────────────────────────────────────
/** Return only visible slots, in order */
export function getVisibleSlots(prefs: ActionBarPreferences): BarItemSlot[] {
return prefs.slots.filter((s) => s.visible);
}
/** Count of currently visible custom items */
export function visibleCount(prefs: ActionBarPreferences): number {
return prefs.slots.filter((s) => s.visible).length;
}
/** Can we show one more item? */
export function canShowMore(prefs: ActionBarPreferences): boolean {
return visibleCount(prefs) < MAX_VISIBLE_SLOTS;
}
/** Toggle visibility of a slot. Enforces MAX_VISIBLE_SLOTS. */
export function toggleSlotVisibility(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
const slot = prefs.slots.find((s) => s.id === id);
if (!slot) return prefs;
// If turning ON and already at max, reject
if (!slot.visible && !canShowMore(prefs)) return prefs;
return {
slots: prefs.slots.map((s) =>
s.id === id ? { ...s, visible: !s.visible } : s,
),
};
}
/** Toggle highlight on a slot (only one can be highlighted at a time) */
export function toggleSlotHighlight(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
return {
slots: prefs.slots.map((s) =>
s.id === id
? { ...s, highlighted: !s.highlighted }
: { ...s, highlighted: false },
),
};
}
/** Move a slot up (earlier) in the list */
export function moveSlotUp(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
const idx = prefs.slots.findIndex((s) => s.id === id);
if (idx <= 0) return prefs;
const newSlots = [...prefs.slots];
[newSlots[idx - 1], newSlots[idx]] = [newSlots[idx], newSlots[idx - 1]];
return { slots: newSlots };
}
/** Move a slot down (later) in the list */
export function moveSlotDown(
prefs: ActionBarPreferences,
id: BarItemId,
): ActionBarPreferences {
const idx = prefs.slots.findIndex((s) => s.id === id);
if (idx < 0 || idx >= prefs.slots.length - 1) return prefs;
const newSlots = [...prefs.slots];
[newSlots[idx], newSlots[idx + 1]] = [newSlots[idx + 1], newSlots[idx]];
return { slots: newSlots };
}
/**
* Validate and repair preferences loaded from localStorage.
* Adds missing candidates, removes unknown ids, preserves order.
*/
export function validatePreferences(raw: unknown): ActionBarPreferences {
if (!raw || typeof raw !== 'object' || !('slots' in raw)) {
return DEFAULT_PREFERENCES;
}
const obj = raw as { slots: unknown };
if (!Array.isArray(obj.slots)) return DEFAULT_PREFERENCES;
const knownIds = new Set<BarItemId>(DEFAULT_PREFERENCES.slots.map((s) => s.id));
const seenIds = new Set<BarItemId>();
// Keep valid existing entries
const cleaned: BarItemSlot[] = [];
for (const item of obj.slots) {
if (
item &&
typeof item === 'object' &&
'id' in item &&
typeof (item as BarItemSlot).id === 'string' &&
knownIds.has((item as BarItemSlot).id) &&
!seenIds.has((item as BarItemSlot).id)
) {
const slot = item as BarItemSlot;
seenIds.add(slot.id);
cleaned.push({
id: slot.id,
visible: typeof slot.visible === 'boolean' ? slot.visible : false,
highlighted: typeof slot.highlighted === 'boolean' ? slot.highlighted : false,
});
}
}
// Add any missing candidates (new features added after user saved prefs)
for (const def of DEFAULT_PREFERENCES.slots) {
if (!seenIds.has(def.id)) {
cleaned.push({ ...def });
}
}
return { slots: cleaned };
}
/**
* Load preferences from localStorage with validation.
*/
export function loadPreferences(): ActionBarPreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_PREFERENCES;
return validatePreferences(JSON.parse(raw));
} catch {
return DEFAULT_PREFERENCES;
}
}
/**
* Save preferences to localStorage.
*/
export function savePreferences(prefs: ActionBarPreferences): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Silently fail (quota, SSR, etc.)
}
}
// ─── Mission Surface Card Visibility ──────────────────────────────────────────
/**
* Load the inline mission card visibility preference.
* Defaults to `true` (visible).
*/
export function loadMissionCardVisible(): boolean {
try {
const raw = localStorage.getItem(MISSION_CARD_STORAGE_KEY);
if (raw === null) return true;
return raw === 'true';
} catch {
return true;
}
}
/**
* Save the inline mission card visibility preference.
*/
export function saveMissionCardVisible(visible: boolean): void {
try {
localStorage.setItem(MISSION_CARD_STORAGE_KEY, String(visible));
} catch {
// Silently fail
}
}
// ─── Visible-in-bar Set Helper ────────────────────────────────────────────────
/**
* Return the set of BarItemIds currently visible in the bottom bar.
* Used by the More dropdown to skip items that are already in the bar.
*/
export function getVisibleBarIds(prefs: ActionBarPreferences): Set<BarItemId> {
return new Set(prefs.slots.filter((s) => s.visible).map((s) => s.id));
}
+316
View File
@@ -0,0 +1,316 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { ExternalLink, GitFork, Package } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { NostrURI } from '@/lib/NostrURI';
import { cn } from '@/lib/utils';
/** Get a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Get all values for a tag name. */
function getAllTags(tags: string[][], name: string): string[] {
return tags.filter(([n]) => n === name).map(([, v]) => v);
}
/** Parse kind-0-style metadata from the content field. */
function parseHandlerMetadata(content: string): NostrMetadata {
if (!content) return {};
try {
return JSON.parse(content) as NostrMetadata;
} catch {
return {};
}
}
/** Get the website URL from web handler tags or metadata. */
function getWebsiteUrl(tags: string[][], metadata: NostrMetadata): string | undefined {
const webTags = tags.filter(([n]) => n === 'web');
for (const tag of webTags) {
const url = tag[1];
if (url) {
const base = url.replace(/<bech32>/g, '').replace(/\/+$/, '');
return base;
}
}
return metadata.website;
}
/** Extract the display domain from a URL. */
function displayDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
/** Build a Shakespeare "Edit with Shakespeare" URL from a kind 30617 `a` tag, if present. */
function getShakespeareUrl(tags: string[][]): string | undefined {
for (const tag of tags) {
if (tag[0] !== 'a') continue;
const parts = tag[1]?.split(':');
if (!parts || parts[0] !== '30617' || parts.length < 3) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
const nostrUri = new NostrURI({ pubkey, identifier }).toString();
return `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrUri)}`;
}
return undefined;
}
interface AppHandlerContentProps {
event: NostrEvent;
/** If true, show compact preview (used in NoteCard feed). */
compact?: boolean;
}
/** Renders a kind 31990 NIP-89 application handler event as a showcase-style card. */
export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const metadata = useMemo(() => parseHandlerMetadata(event.content), [event.content]);
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
const about = metadata.about;
const picture = metadata.picture;
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
const thumbnailUrl = preview?.thumbnail_url;
const [imgError, setImgError] = useState(false);
const showThumbnail = thumbnailUrl && !imgError;
if (compact) {
return (
<div className="mt-2">
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
</div>
)}
{/* Content */}
<div className="relative z-10 px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className={showThumbnail || previewLoading ? '-mt-7' : 'pt-3.5'}>
{picture ? (
<img
src={picture}
alt={name}
className="size-14 rounded-xl object-cover shrink-0 border-3 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
<Package className="size-6 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h3 className="font-semibold text-[15px] leading-snug truncate">{name}</h3>
{websiteUrl && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={12} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground line-clamp-2">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.slice(0, 4).map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
{websiteUrl && (
<Button asChild size="sm" className="h-7 text-xs">
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm" className="h-7 text-xs">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3 ml-1" />
</a>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
// Full detail view
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
</div>
)}
{/* Content */}
<div className="relative z-10 px-4 pb-4 space-y-3">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className={cn(
'flex items-end justify-between',
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
)}>
{picture ? (
<img
src={picture}
alt={name}
className="size-20 rounded-2xl object-cover shrink-0 border-4 border-background bg-background shadow-sm"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
<Package className="size-8 text-primary/50" />
</div>
)}
</div>
{/* Name + domain */}
<div className="min-w-0">
<h2 className="text-xl font-semibold leading-snug truncate">{name}</h2>
{websiteUrl && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<ExternalFavicon url={websiteUrl} size={14} />
<span className="truncate">{displayDomain(websiteUrl)}</span>
</div>
)}
</div>
{/* Description */}
{about && (
<p className="text-sm text-muted-foreground leading-relaxed">{about}</p>
)}
{/* Tags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
#{tag}
</Link>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{websiteUrl && (
<Button asChild size="sm">
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
</a>
</Button>
)}
{shakespeareUrl && (
<Button asChild variant="secondary" size="sm">
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Fork
<GitFork className="size-3.5 ml-1.5" />
</a>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
/** Skeleton loading state for AppHandlerContent. */
export function AppHandlerSkeleton() {
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
<Skeleton className="aspect-[2/1] w-full" />
<div className="px-4 pb-4 space-y-3">
<div className="-mt-10">
<Skeleton className="size-20 rounded-2xl border-4 border-background" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-24" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
</div>
</div>
);
}
+1 -8
View File
@@ -1,7 +1,7 @@
import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
import { AppConfigSchema } from '@/lib/schemas';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
@@ -47,13 +47,6 @@ export function AppProvider(props: AppProviderProps) {
}
}
// Migrate legacy theme values ("black", "pink") to "custom" + customTheme
const legacyTheme = result.theme as string | undefined;
if (legacyTheme && legacyTheme in themePresets) {
result.theme = 'custom';
result.customTheme = { colors: themePresets[legacyTheme].colors };
}
// Migrate legacy blossomServers (string[]) to blossomServerMetadata
if (!result.blossomServerMetadata) {
const legacyServers = parsed.blossomServers;
+15
View File
@@ -108,6 +108,7 @@ const KIND_LABELS: Record<number, string> = {
30030: 'an emoji pack',
30054: 'a podcast episode',
30055: 'a podcast trailer',
3063: 'an asset',
30063: 'a release',
30311: 'a stream',
30315: 'a status',
@@ -115,6 +116,7 @@ const KIND_LABELS: Record<number, string> = {
30817: 'a custom NIP',
31922: 'a calendar event',
31923: 'a calendar event',
31990: 'an app',
32267: 'an app',
34139: 'a playlist',
34236: 'a divine',
@@ -154,9 +156,11 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
30030: SmilePlus,
30054: Podcast,
30055: Podcast,
3063: Package,
30063: Package,
30311: Radio,
30617: GitBranch,
31990: Package,
32267: Package,
34236: Clapperboard,
36767: Sparkles,
@@ -214,6 +218,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
const KIND_POSTFIXES: Partial<Record<number, string>> = {
32267: 'on Zapstore',
30063: 'release',
3063: 'asset',
};
/** Get a display name for an event based on its kind and tags. */
@@ -228,6 +233,16 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
}
// NIP-89 apps: name lives in JSON content, not in tags
if (event.kind === 31990) {
try {
const meta = JSON.parse(event.content);
const appName = meta?.name || event.tags.find(([n]) => n === 'name')?.[1];
if (appName) return { text: `${appName} app`, icon };
} catch { /* fall through */ }
return { text: 'an app', icon };
}
// Extract a title-like string from tags
const title = event.tags.find(([name]) => name === 'title')?.[1];
const name = event.tags.find(([name]) => name === 'name')?.[1];
+23 -16
View File
@@ -224,6 +224,26 @@ export function ComposeBox({
const voiceRecorder = useVoiceRecorder();
const [isPublishingVoice, setIsPublishingVoice] = useState(false);
const resetComposeState = useCallback(() => {
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setPickerOpen(false);
setTrayOpen(false);
setInternalPreviewMode(false);
setMode(initialMode);
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
}, [initialMode]);
// Use controlled preview mode if provided, otherwise use internal state
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
@@ -928,15 +948,7 @@ export function ComposeBox({
});
}
setContent('');
setCwEnabled(false);
setCwText('');
setExpanded(false);
setRemovedEmbeds(new Set());
setUploadedFileTags([]);
setUploadedFileGroups(new Map());
setWebxdcUuids(new Map());
setWebxdcMetas(new Map());
resetComposeState();
// Optimistically bump the reply count on the parent event
if (replyTo && !(replyTo instanceof URL)) {
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
@@ -984,12 +996,7 @@ export function ComposeBox({
try {
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
// Reset poll state
setMode('post');
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
toast({ title: 'Poll published!' });
onSuccess?.();
@@ -1007,7 +1014,7 @@ export function ComposeBox({
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3 bg-background/50")}>
<div className={cn("px-4 py-3 bg-background/85")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
+3 -3
View File
@@ -15,7 +15,7 @@ interface CustomEmojiImgProps {
/**
* Renders a single custom emoji as an inline image.
*/
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] align-text-bottom' }: CustomEmojiImgProps) {
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
return (
<img
src={url}
@@ -52,7 +52,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
const url = getCustomEmojiUrl(emoji, tags);
if (url) {
const name = emoji.slice(1, -1);
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-text-bottom'} />;
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom'} />;
}
}
@@ -70,7 +70,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
*/
export function RenderResolvedEmoji({ emoji, className }: { emoji: ResolvedEmoji; className?: string }) {
if (emoji.url && emoji.name) {
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-middle'} />;
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-middle'} />;
}
return <span className={cn('inline-block leading-none', className)}>{emoji.content}</span>;
}
+3 -1
View File
@@ -1082,8 +1082,10 @@ function hasVideo(tags: string[][]): boolean {
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
31990: 'App',
32267: 'App',
30063: 'Release',
3063: 'Asset',
15128: 'Nsite',
35128: 'Nsite',
31124: 'Blobbi',
@@ -1109,7 +1111,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
const KindIcon = useMemo(() => {
if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText;
// Fallback icons for well-known kinds not in EXTRA_KINDS
if (addr.kind === 32267 || addr.kind === 30063) return Package;
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package;
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
return FileText;
}, [kindDef, addr.kind]);
+8 -4
View File
@@ -108,10 +108,14 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const { startSignup } = useOnboarding();
// Kind-specific pages only support Follows + Global. Clamp any other
// persisted tab (e.g. 'ditto', 'communities') back to 'follows'.
const activeTab: FeedTab = kinds && rawActiveTab !== 'follows' && rawActiveTab !== 'global'
? 'follows'
: rawActiveTab;
// persisted tab (e.g. 'ditto', 'communities') back to the appropriate default.
// Logged-out users must land on 'global' since 'follows' requires a user.
const activeTab: FeedTab = (() => {
if (!kinds) return rawActiveTab; // Home feed: no clamping
if (rawActiveTab === 'global') return 'global';
if (rawActiveTab === 'follows' && user) return 'follows';
return user ? 'follows' : 'global';
})();
// Is the active tab a saved feed?
const activeSavedFeed = useMemo(
+1 -1
View File
@@ -184,7 +184,7 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
{/* Emoji group header */}
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
{customUrl && customName ? (
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6" />
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6 object-contain" />
) : (
<span className="text-lg">{emoji}</span>
)}
+8 -3
View File
@@ -1,7 +1,12 @@
import { Link } from 'react-router-dom';
interface LinkFooterProps {
/** Optional callback fired when an internal (React Router) link is clicked. */
onNavigate?: () => void;
}
/** Shared footer links used in both sidebars. */
export function LinkFooter() {
export function LinkFooter({ onNavigate }: LinkFooterProps) {
return (
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
<p className="text-xs text-muted-foreground">
@@ -23,7 +28,7 @@ export function LinkFooter() {
Docs
</a>
{' · '}
<Link to="/privacy" className="text-primary hover:underline">
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
Privacy
</Link>
{' · '}
@@ -36,7 +41,7 @@ export function LinkFooter() {
Source
</a>
{' · '}
<Link to="/changelog" className="text-primary hover:underline">
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
Changelog
</Link>
{' · '}
-1
View File
@@ -108,7 +108,6 @@ function MainLayoutInner() {
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const { config } = useAppContext();
const { hidden: navHidden } = useScrollDirection(scrollContainer);
return (
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={navHidden}>
+2 -2
View File
@@ -319,7 +319,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</nav>
<div className="px-2">
<LinkFooter />
<LinkFooter onNavigate={handleClose} />
</div>
</div>
) : (
@@ -363,7 +363,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</nav>
<div className="px-2">
<LinkFooter />
<LinkFooter onNavigate={handleClose} />
</div>
</div>
)}
+2 -2
View File
@@ -109,8 +109,8 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
.filter(r => r.read)
.map(r => r.url);
// Include zapstore relay for kind 32267 (apps) and 30063 (releases)
const ZAPSTORE_KINDS = [32267, 30063];
// Include zapstore relay for kind 32267 (apps), 30063 (releases), and 3063 (assets)
const ZAPSTORE_KINDS = [32267, 30063, 3063];
if (filters.every((f) => f?.kinds?.every((k) => ZAPSTORE_KINDS.includes(k)))) {
return new Map([ZAPSTORE_RELAY, ...readRelays].map(url => [url, filters]));
}
+2 -14
View File
@@ -9,7 +9,7 @@ import { isSyncDone } from "@/hooks/useInitialSync";
import { parseBlossomServerList } from "@/lib/appBlossom";
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
import type { ThemeConfig } from "@/themes";
import { themePresets } from "@/themes";
/**
* NostrSync - Syncs user's Nostr data
@@ -285,19 +285,7 @@ export function NostrSync() {
const updates = { ...current };
if (encryptedSettings.theme) {
// Migrate legacy theme values ("black", "pink") from older encrypted settings
const remoteTheme = encryptedSettings.theme as string;
if (remoteTheme in themePresets) {
if (
current.theme !== "custom" ||
JSON.stringify(current.customTheme?.colors) !==
JSON.stringify(themePresets[remoteTheme].colors)
) {
updates.theme = "custom";
updates.customTheme = { colors: themePresets[remoteTheme].colors };
changed = true;
}
} else if (encryptedSettings.theme !== current.theme) {
if (encryptedSettings.theme !== current.theme) {
updates.theme = encryptedSettings.theme;
changed = true;
}
+30 -4
View File
@@ -74,6 +74,8 @@ import { EncryptedMessageContent } from "@/components/EncryptedMessageContent";
import { EncryptedLetterContent } from "@/components/EncryptedLetterContent";
import { VanishCardCompact } from "@/components/VanishEventContent";
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
import { ZapstoreReleaseContent, ZapstoreAssetContent } from "@/components/ZapstoreReleaseContent";
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getAvatarShape } from "@/lib/avatarShape";
import { Badge } from "@/components/ui/badge";
@@ -306,6 +308,9 @@ export const NoteCard = memo(function NoteCard({
const isCustomNip = event.kind === 30817;
const isNsite = event.kind === 15128 || event.kind === 35128;
const isZapstoreApp = event.kind === 32267;
const isZapstoreRelease = event.kind === 30063;
const isZapstoreAsset = event.kind === 3063;
const isAppHandler = event.kind === 31990;
const isEncryptedDM = event.kind === 4;
const isLetter = event.kind === 8211;
const isVanish = event.kind === 62;
@@ -336,6 +341,9 @@ export const NoteCard = memo(function NoteCard({
!isAudioKind &&
!isDevKind &&
!isZapstoreApp &&
!isZapstoreRelease &&
!isZapstoreAsset &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
!isVanish &&
@@ -532,6 +540,12 @@ export const NoteCard = memo(function NoteCard({
<NsiteCard event={event} />
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} compact />
) : isZapstoreRelease ? (
<ZapstoreReleaseContent event={event} compact />
) : isZapstoreAsset ? (
<ZapstoreAssetContent event={event} compact />
) : isAppHandler ? (
<AppHandlerContent event={event} compact />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} compact />
) : isLetter ? (
@@ -789,11 +803,11 @@ export const NoteCard = memo(function NoteCard({
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Reaction emoji bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-lg leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-lg leading-none"
className="h-5 w-5 object-contain"
/>
</div>
{threaded && (
@@ -870,11 +884,11 @@ export const NoteCard = memo(function NoteCard({
>
<div className="flex items-center gap-3">
{/* Large reaction emoji */}
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-xl leading-none"
className="h-6 w-6 object-contain"
/>
</div>
@@ -2002,6 +2016,18 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
icon: Package,
action: "published an app",
},
30063: {
icon: Package,
action: "published a release",
},
3063: {
icon: Package,
action: "published an asset",
},
31990: {
icon: Package,
action: "published an app",
},
30617: {
icon: GitBranch,
action: "shared a",
+1 -1
View File
@@ -545,7 +545,7 @@ export function NoteContent({
{groupedTokens.map((token, i) => {
switch (token.type) {
case 'text':
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 align-text-bottom' : undefined))}</span>;
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
case 'image-embed': {
if (disableEmbeds) {
// In preview contexts (e.g. triple-dot menu), replace image URLs
+31 -11
View File
@@ -15,6 +15,8 @@ import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import type { AddrCoords } from '@/hooks/useEvent';
import QRCode from 'qrcode';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { getContentWarning } from '@/lib/contentWarning';
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
@@ -23,6 +25,9 @@ import { parseDimToAspectRatio } from '@/components/MediaCollage';
import { isWeatherFieldLabel } from '@/lib/weatherStation';
import { WeatherStationCard } from '@/components/WeatherStationCard';
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
/** Simple email regex for display purposes. */
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -65,10 +70,8 @@ interface ProfileField {
interface ProfileRightSidebarProps {
fields?: ProfileField[];
/** Media events fetched via a dedicated search query (video:true image:true). */
mediaEvents?: NostrEvent[];
/** Whether the media events are still loading. */
mediaLoading?: boolean;
/** Pubkey whose media-native events to display in the sidebar. */
pubkey?: string;
/** Called when a media tile is clicked. If provided, tiles don't navigate. */
onMediaClick?: (url: string) => void;
/** Override the root element's className (e.g. to show on mobile). */
@@ -485,20 +488,37 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
return rows;
}
export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick, className }: ProfileRightSidebarProps) {
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
const { config } = useAppContext();
const { nostr } = useNostr();
// Fetch media-native events directly using a kind whitelist (no search extension).
const { data: sidebarEvents, isPending: mediaLoading } = useQuery({
queryKey: ['sidebar-media', pubkey ?? ''],
queryFn: async ({ signal }) => {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const events = await nostr.query(
[{ kinds: SIDEBAR_MEDIA_KINDS, authors: [pubkey!], limit: 20 }],
{ signal: querySignal },
);
const now = Math.floor(Date.now() / 1000);
return events.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
},
enabled: !!pubkey,
staleTime: 30_000,
});
const media = useMemo(
() => extractMedia(mediaEvents ?? [], config.contentWarningPolicy),
[mediaEvents, config.contentWarningPolicy],
() => extractMedia(sidebarEvents ?? [], config.contentWarningPolicy),
[sidebarEvents, config.contentWarningPolicy],
);
const mediaLoading = mediaLoadingProp ?? false;
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
return (
<aside className={cn("w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
{/* Media Section — only shown when mediaEvents prop is provided */}
{mediaEvents !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
{/* Media Section — only shown when pubkey prop is provided */}
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
{mediaLoading ? (
<div className="flex flex-col gap-0.5">
@@ -608,7 +628,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
)}
{/* Footer — hidden when used as a fields-only preview */}
{mediaEvents !== undefined && <LinkFooter />}
{pubkey !== undefined && <LinkFooter />}
</aside>
);
}
+1 -1
View File
@@ -186,7 +186,7 @@ export function ReactionButton({
{filledHeart ? (
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
) : hasReacted && userReaction ? (
<RenderResolvedEmoji emoji={userReaction} className="size-5 leading-none translate-y-px" />
<RenderResolvedEmoji emoji={userReaction} className="h-5 w-5 object-contain leading-none translate-y-px" />
) : (
<Heart className="size-5" />
)}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { toast } from '@/hooks/useToast';
import { ToastAction } from '@/components/ui/toast';
import { parseChangelog } from '@/lib/changelog';
const STORAGE_KEY = 'ditto:app-version';
/** Fetch the first changelog item for the given version (or the latest entry). */
async function fetchChangelogExcerpt(version: string): Promise<string | undefined> {
try {
const res = await fetch('/CHANGELOG.md');
if (!res.ok) return undefined;
const markdown = await res.text();
const entries = parseChangelog(markdown);
// Try to find the entry matching the current version, otherwise use the first entry.
const entry = entries.find((e) => e.version === version) ?? entries[0];
if (!entry) return undefined;
// Return a truncated first item from the first section.
const item = entry.sections[0]?.items[0];
if (!item) return undefined;
if (item.length <= 60) return item;
return item.slice(0, 60).trimEnd() + '…';
} catch {
return undefined;
}
}
/** Compares the running app version against localStorage and shows a toast when the version changes. */
export function VersionCheck() {
useEffect(() => {
const currentVersion = import.meta.env.VERSION;
if (!currentVersion) return;
const storedVersion = localStorage.getItem(STORAGE_KEY);
localStorage.setItem(STORAGE_KEY, currentVersion);
if (storedVersion && storedVersion !== currentVersion) {
// Show the toast immediately, then enrich it with a changelog excerpt.
const { update, id } = toast({
title: `What's new in v${currentVersion}`,
action: (
<ToastAction altText="View changelog" asChild>
<Link to="/changelog">See all</Link>
</ToastAction>
),
});
fetchChangelogExcerpt(currentVersion).then((excerpt) => {
if (excerpt) {
update({
id,
title: `What's new in v${currentVersion}`,
description: excerpt,
action: (
<ToastAction altText="View changelog" asChild>
<Link to="/changelog">See all</Link>
</ToastAction>
),
});
}
});
}
}, []);
return null;
}
+722
View File
@@ -0,0 +1,722 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import {
Package,
Download,
Tag,
Hash,
Smartphone,
Monitor,
Globe,
Shield,
ExternalLink,
GitCommit,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
import { openUrl } from '@/lib/downloadFile';
/** Get a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Get all tag entries for a tag name. */
function getAllTagEntries(tags: string[][], name: string): string[][] {
return tags.filter(([n]) => n === name);
}
/** Get all values for a tag name. */
function getAllTags(tags: string[][], name: string): string[] {
return tags.filter(([n]) => n === name).map(([, v]) => v);
}
/** Map a MIME type to a human-readable platform label. */
function mimeToLabel(mime: string): string {
const map: Record<string, string> = {
'application/vnd.android.package-archive': 'Android APK',
'application/vnd.apple.ipa': 'iOS IPA',
'application/x-apple-diskimage': 'macOS DMG',
'application/vnd.apple.installer+xml': 'macOS PKG',
'application/x-msi': 'Windows MSI',
'application/vnd.appimage': 'Linux AppImage',
'application/vnd.flatpak': 'Linux Flatpak',
'application/x-executable': 'Linux Binary',
'application/x-mach-binary': 'macOS Binary',
'application/vnd.microsoft.portable-executable': 'Windows EXE',
'application/vsix': 'VS Code Extension',
'application/x-chrome-extension': 'Chrome Extension',
'application/x-xpinstall': 'Firefox Extension',
'application/wasm': 'WebAssembly',
'application/webbundle': 'Web Bundle',
'application/vnd.oci.image.manifest.v1+json': 'OCI Image',
};
return map[mime] ?? mime;
}
/** Return a platform icon component for a MIME type. */
function PlatformIcon({ mime, className }: { mime: string; className?: string }) {
if (mime.includes('android') || mime.includes('apple.ipa')) {
return <Smartphone className={className} />;
}
if (mime.includes('apple') || mime.includes('mach') || mime.includes('msi') || mime.includes('portable-executable')) {
return <Monitor className={className} />;
}
if (mime.includes('appimage') || mime.includes('flatpak') || mime.includes('executable')) {
return <Monitor className={className} />;
}
if (mime.includes('wasm') || mime.includes('webbundle') || mime.includes('chrome') || mime.includes('xpinstall') || mime.includes('vsix')) {
return <Globe className={className} />;
}
return <Package className={className} />;
}
/** Format file size for display. */
function formatSize(bytes: string | undefined): string | undefined {
if (!bytes) return undefined;
const n = parseInt(bytes, 10);
if (isNaN(n)) return bytes;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`;
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} KB`;
return `${n} B`;
}
/** Map platform identifier to OS label. */
function platformLabel(f: string): string {
const map: Record<string, string> = {
'android-arm64-v8a': 'ARM64',
'android-armeabi-v7a': 'ARMv7',
'android-x86': 'x86',
'android-x86_64': 'x64',
'darwin-arm64': 'Apple Silicon',
'darwin-x86_64': 'Intel',
'linux-aarch64': 'ARM64',
'linux-x86_64': 'x64',
'linux-armv7l': 'ARMv7',
'linux-riscv64': 'RISC-V',
'windows-aarch64': 'ARM64',
'windows-x86_64': 'x64',
'ios-arm64': 'ARM64',
'wasm32': 'WASM32',
'wasm64': 'WASM64',
'wasi-wasm32': 'WASI',
'wasi-wasm64': 'WASI64',
};
return map[f] ?? f;
}
/** Channel label with color. */
function ChannelBadge({ channel }: { channel: string }) {
const variants: Record<string, string> = {
main: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
beta: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
nightly: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
dev: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
};
const colorClass = variants[channel] ?? 'bg-muted text-muted-foreground';
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}>
{channel}
</span>
);
}
/** Hook to fetch asset events (kind 3063) for a release. */
function useReleaseAssets(assetIds: string[]) {
const { nostr } = useNostr();
return useQuery<NostrEvent[]>({
queryKey: ['zapstore-assets', ...assetIds.sort()],
queryFn: async ({ signal }) => {
if (assetIds.length === 0) return [];
try {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
// Try the Zapstore relay first
const events = await nostr.relay(ZAPSTORE_RELAY).query(
[{ kinds: [3063], ids: assetIds }],
{ signal: querySignal },
);
if (events.length > 0) return events;
// Fallback to the default pool
const fallbackSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const fallback = await nostr.query(
[{ kinds: [3063], ids: assetIds }],
{ signal: fallbackSignal },
);
return fallback;
} catch {
return [];
}
},
enabled: assetIds.length > 0,
staleTime: 10 * 60 * 1000,
});
}
/** Hook to fetch the linked app event (kind 32267) for a release. */
function useReleaseApp(appIdentifier: string | undefined, releasePubkey: string) {
const { nostr } = useNostr();
return useQuery<NostrEvent | null>({
queryKey: ['zapstore-app-for-release', appIdentifier, releasePubkey],
queryFn: async ({ signal }) => {
if (!appIdentifier) return null;
try {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const events = await nostr.relay(ZAPSTORE_RELAY).query(
[{ kinds: [32267], authors: [releasePubkey], '#d': [appIdentifier], limit: 1 }],
{ signal: querySignal },
);
if (events.length > 0) return events[0];
const fallbackSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const fallback = await nostr.query(
[{ kinds: [32267], authors: [releasePubkey], '#d': [appIdentifier], limit: 1 }],
{ signal: fallbackSignal },
);
return fallback.length > 0 ? fallback[0] : null;
} catch {
return null;
}
},
enabled: !!appIdentifier,
staleTime: 5 * 60 * 1000,
});
}
/** Single asset download row. */
function AssetRow({ event }: { event: NostrEvent }) {
const mime = getTag(event.tags, 'm') ?? '';
const url = getTag(event.tags, 'url');
const version = getTag(event.tags, 'version');
const size = formatSize(getTag(event.tags, 'size'));
const platforms = getAllTags(event.tags, 'f');
const variant = getTag(event.tags, 'variant');
const commit = getTag(event.tags, 'commit');
const hash = getTag(event.tags, 'x');
const label = mimeToLabel(mime);
const platformLabels = platforms.map(platformLabel);
const handleDownload = async () => {
if (url) {
await openUrl(url);
}
};
return (
<div className="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-muted/50 transition-colors group">
{/* Platform icon */}
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<PlatformIcon mime={mime} className="size-4 text-primary" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{label}</span>
{variant && (
<Badge variant="outline" className="text-xs px-1.5 py-0">
{variant}
</Badge>
)}
{platformLabels.length > 0 && (
<span className="text-xs text-muted-foreground">
{platformLabels.join(', ')}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{version && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Tag className="size-3" />
{version}
</span>
)}
{size && (
<span className="text-xs text-muted-foreground">{size}</span>
)}
{commit && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<GitCommit className="size-3" />
<code className="font-mono">{commit.slice(0, 7)}</code>
</span>
)}
{hash && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Hash className="size-3" />
<code className="font-mono">{hash.slice(0, 8)}</code>
</span>
)}
</div>
</div>
{/* Download button */}
{url && (
<Button
size="sm"
variant="ghost"
className="gap-1.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { e.stopPropagation(); handleDownload(); }}
>
<Download className="size-3.5" />
Download
</Button>
)}
</div>
);
}
interface ZapstoreReleaseContentProps {
event: NostrEvent;
/** If true, show compact preview (used in NoteCard feed). */
compact?: boolean;
}
/** Renders a kind 30063 Zapstore release event. */
export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseContentProps) {
const version = getTag(event.tags, 'version');
const channel = getTag(event.tags, 'c') ?? 'main';
const appIdentifier = getTag(event.tags, 'i');
// Collect asset event IDs from `e` tags
const assetEntries = useMemo(() => getAllTagEntries(event.tags, 'e'), [event.tags]);
const assetIds = useMemo(() => assetEntries.map(([, id]) => id).filter(Boolean), [assetEntries]);
const { data: assets = [], isLoading: assetsLoading } = useReleaseAssets(assetIds);
const { data: appEvent } = useReleaseApp(appIdentifier, event.pubkey);
const appName = appEvent
? (getTag(appEvent.tags, 'name') || getTag(appEvent.tags, 'd') || appIdentifier)
: appIdentifier;
const appIcon = appEvent ? getTag(appEvent.tags, 'icon') : undefined;
const appId = appEvent ? getTag(appEvent.tags, 'd') : appIdentifier;
// Build naddr link to the app event if we have it
const appNaddr = appEvent
? nip19.naddrEncode({ kind: 32267, pubkey: appEvent.pubkey, identifier: getTag(appEvent.tags, 'd') ?? '' })
: undefined;
const releaseNotes = event.content;
if (compact) {
return (
<div className="mt-2 space-y-2.5">
{/* Header: icon + app name + version */}
<div className="flex items-start gap-3">
{appIcon ? (
<img
src={appIcon}
alt={appName ?? ''}
className="size-10 rounded-xl object-cover shrink-0 shadow-sm"
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLElement).style.display = 'none'; }}
/>
) : (
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<Package className="size-5 text-primary/50" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{appName && (
appNaddr ? (
<Link
to={`/${appNaddr}`}
className="font-semibold text-[15px] leading-snug hover:underline"
onClick={(e) => e.stopPropagation()}
>
{appName}
</Link>
) : (
<span className="font-semibold text-[15px] leading-snug">{appName}</span>
)
)}
{version && (
<Badge variant="outline" className="text-xs px-2 py-0">
v{version}
</Badge>
)}
<ChannelBadge channel={channel} />
</div>
{/* Asset count summary */}
{assetIds.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5">
{assetIds.length} {assetIds.length === 1 ? 'asset' : 'assets'} available
</p>
)}
</div>
</div>
{/* Release notes (truncated) */}
{releaseNotes && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap break-words line-clamp-4">
{releaseNotes}
</p>
)}
</div>
);
}
// Full detail view
return (
<div className="mt-3 space-y-4">
{/* Header */}
<div className="flex items-start gap-4">
{appIcon ? (
<img
src={appIcon}
alt={appName ?? ''}
className="size-14 rounded-2xl object-cover shrink-0 shadow-md"
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLElement).style.display = 'none'; }}
/>
) : (
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
<Package className="size-7 text-primary/50" />
</div>
)}
<div className="flex-1 min-w-0">
{appName && (
appNaddr ? (
<Link
to={`/${appNaddr}`}
className="text-lg font-bold leading-snug hover:underline"
onClick={(e) => e.stopPropagation()}
>
{appName}
</Link>
) : (
<h2 className="text-lg font-bold leading-snug">{appName}</h2>
)
)}
<div className="flex items-center gap-2 flex-wrap mt-1.5">
{version && (
<Badge variant="secondary" className="text-xs px-2 py-0">
v{version}
</Badge>
)}
<ChannelBadge channel={channel} />
</div>
</div>
</div>
{/* Action row */}
{appId && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="gap-1.5" asChild>
<a
href={`https://zapstore.dev/apps/${encodeURIComponent(appId)}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3.5" />
View on Zapstore
</a>
</Button>
{appNaddr && (
<Button size="sm" variant="ghost" className="gap-1.5" asChild>
<Link to={`/${appNaddr}`} onClick={(e) => e.stopPropagation()}>
<Package className="size-3.5" />
App details
</Link>
</Button>
)}
</div>
)}
{/* Release notes */}
{releaseNotes && (
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Release Notes
</p>
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">
{releaseNotes}
</p>
</div>
)}
{/* Assets */}
{assetIds.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground px-1">
Downloads
</p>
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
{assetsLoading
? Array.from({ length: Math.min(assetIds.length, 3) }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="size-8 rounded-lg shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))
: assets.length > 0
? assets.map((asset) => (
<AssetRow key={asset.id} event={asset} />
))
: assetIds.map((id) => (
<div key={id} className="flex items-center gap-3 px-3 py-2.5">
<div className="size-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Package className="size-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground font-mono truncate">{id.slice(0, 16)}</p>
</div>
</div>
))
}
</div>
</div>
)}
</div>
);
}
/** Skeleton loading state for ZapstoreReleaseContent. */
export function ZapstoreReleaseSkeleton() {
return (
<div className="mt-3 space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-14 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-28" />
<div className="flex gap-2">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-5 w-10 rounded-full" />
</div>
</div>
</div>
<Skeleton className="h-8 w-36 rounded-md" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
{[1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="size-8 rounded-lg shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// kind 3063 — Software Asset card
// ---------------------------------------------------------------------------
interface ZapstoreAssetContentProps {
event: NostrEvent;
compact?: boolean;
}
/** Renders a kind 3063 Zapstore software asset event. */
export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentProps) {
const mime = getTag(event.tags, 'm') ?? '';
const url = getTag(event.tags, 'url');
const version = getTag(event.tags, 'version');
const size = formatSize(getTag(event.tags, 'size'));
const appIdentifier = getTag(event.tags, 'i');
const platforms = getAllTags(event.tags, 'f');
const variant = getTag(event.tags, 'variant');
const commit = getTag(event.tags, 'commit');
const hash = getTag(event.tags, 'x');
const supportedNips = getAllTags(event.tags, 'supported_nip');
const minPlatformVersion = getTag(event.tags, 'min_platform_version');
const label = mimeToLabel(mime);
const platformLabels = platforms.map(platformLabel);
const handleDownload = async () => {
if (url) {
await openUrl(url);
}
};
if (compact) {
return (
<div className="mt-2 space-y-2">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<PlatformIcon mime={mime} className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-[15px] leading-snug">{label}</span>
{variant && (
<Badge variant="outline" className="text-xs px-1.5 py-0">{variant}</Badge>
)}
{version && (
<span className="text-xs text-muted-foreground">v{version}</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
{appIdentifier && <span>{appIdentifier}</span>}
{platformLabels.length > 0 && <span>{platformLabels.join(', ')}</span>}
{size && <span>{size}</span>}
</div>
</div>
</div>
</div>
);
}
return (
<div className="mt-3 space-y-4">
{/* Header */}
<div className="flex items-start gap-4">
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
<PlatformIcon mime={mime} className="size-7 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold leading-snug">{label}</h2>
{appIdentifier && (
<p className="text-sm text-muted-foreground mt-0.5">{appIdentifier}</p>
)}
<div className="flex items-center gap-2 flex-wrap mt-2">
{version && (
<Badge variant="secondary" className="text-xs px-2 py-0">
v{version}
</Badge>
)}
{variant && (
<Badge variant="outline" className="text-xs px-2 py-0">{variant}</Badge>
)}
{platformLabels.length > 0 && (
platformLabels.map((p) => (
<Badge key={p} variant="outline" className="text-xs px-2 py-0">{p}</Badge>
))
)}
</div>
</div>
</div>
{/* Download button */}
{url && (
<Button
size="sm"
className="gap-1.5"
onClick={(e) => { e.stopPropagation(); handleDownload(); }}
>
<Download className="size-3.5" />
Download
</Button>
)}
{/* Metadata grid */}
<div className="rounded-xl border border-border divide-y divide-border">
{size && (
<MetaRow label="File Size" value={size} />
)}
{mime && (
<MetaRow label="MIME Type" value={<code className="text-xs font-mono">{mime}</code>} />
)}
{hash && (
<MetaRow label="SHA-256" value={<code className="text-xs font-mono break-all">{hash}</code>} />
)}
{commit && (
<MetaRow
label="Commit"
value={
<span className="flex items-center gap-1">
<GitCommit className="size-3 shrink-0" />
<code className="text-xs font-mono">{commit}</code>
</span>
}
/>
)}
{minPlatformVersion && (
<MetaRow label="Min Platform Version" value={minPlatformVersion} />
)}
{supportedNips.length > 0 && (
<MetaRow
label="Supported NIPs"
value={
<div className="flex flex-wrap gap-1">
{supportedNips.map((nip) => (
<Badge key={nip} variant="secondary" className="text-xs px-1.5 py-0">
NIP-{nip}
</Badge>
))}
</div>
}
/>
)}
</div>
{/* Certificate hashes (Android) */}
{getAllTags(event.tags, 'apk_certificate_hash').length > 0 && (
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
APK Certificate
</p>
{getAllTags(event.tags, 'apk_certificate_hash').map((hash) => (
<div key={hash} className="flex items-center gap-2">
<Shield className="size-3.5 text-green-600 shrink-0" />
<code className="text-xs font-mono text-muted-foreground break-all">{hash}</code>
</div>
))}
</div>
)}
</div>
);
}
/** A single metadata row inside the asset details grid. */
function MetaRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-4 px-3 py-2">
<span className="text-xs text-muted-foreground w-36 shrink-0 pt-0.5">{label}</span>
<span className="text-sm flex-1 min-w-0">{value}</span>
</div>
);
}
/** Skeleton for ZapstoreAssetContent. */
export function ZapstoreAssetSkeleton() {
return (
<div className="mt-3 space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-14 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-5 w-14 rounded-full" />
</div>
</div>
</div>
<Skeleton className="h-8 w-28 rounded-md" />
<div className="rounded-xl border border-border divide-y divide-border">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-4 px-3 py-2">
<Skeleton className="h-3 w-28 mt-0.5 shrink-0" />
<Skeleton className="h-3 w-48 flex-1" />
</div>
))}
</div>
</div>
);
}
// Re-export Separator so it's available if needed
export { Separator };
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface LinkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedText?: string;
onSubmit: (text: string, url: string) => void;
}
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
const [text, setText] = useState('');
const [url, setUrl] = useState('');
// Reset form when dialog opens
useEffect(() => {
if (open) {
setText(selectedText || '');
setUrl('');
}
}, [open, selectedText]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (url.trim()) {
const finalText = text.trim() || url.trim();
let finalUrl = url.trim();
// Add https:// if no protocol specified
if (!/^https?:\/\//i.test(finalUrl)) {
finalUrl = 'https://' + finalUrl;
}
onSubmit(finalText, finalUrl);
onOpenChange(false);
}
};
const hasSelectedText = !!selectedText;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{!hasSelectedText && (
<div className="space-y-2">
<Label htmlFor="link-text">Link Text</Label>
<Input
id="link-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter link text..."
autoFocus={!hasSelectedText}
/>
</div>
)}
{hasSelectedText && (
<div className="space-y-2">
<Label className="text-muted-foreground">Link Text</Label>
<p className="text-sm bg-muted px-3 py-2 rounded-md">{selectedText}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
autoFocus={hasSelectedText}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!url.trim()}>
Insert Link
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+393
View File
@@ -0,0 +1,393 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, insertHrCommand, turnIntoTextCommand, wrapInHeadingCommand, toggleInlineCodeCommand, wrapInBulletListCommand, wrapInOrderedListCommand } from '@milkdown/preset-commonmark';
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
import { history } from '@milkdown/plugin-history';
import { clipboard } from '@milkdown/plugin-clipboard';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { upload, uploadConfig } from '@milkdown/plugin-upload';
import { Decoration } from '@milkdown/prose/view';
import { replaceAll, callCommand } from '@milkdown/utils';
import { MilkdownToolbar } from './MilkdownToolbar';
import { LinkDialog } from './LinkDialog';
interface MilkdownEditorInnerProps {
value: string;
onChange: (markdown: string) => void;
onBlur?: () => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
showToolbar?: boolean;
sourceMode?: boolean;
onToggleSource?: () => void;
}
function MilkdownEditorInner({ value, onChange, onBlur, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
const initialValueRef = useRef(value);
const editorRef = useRef<Editor | null>(null);
const lastExternalValue = useRef(value);
const onUploadImageRef = useRef(onUploadImage);
const fileInputRef = useRef<HTMLInputElement>(null);
// Link dialog state
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
const selectionRef = useRef<{ from: number; to: number } | null>(null);
// Keep refs in sync so Milkdown remounts (e.g. source mode toggle) use
// the latest value rather than the stale value captured on first render.
useEffect(() => {
initialValueRef.current = value;
onUploadImageRef.current = onUploadImage;
}, [value, onUploadImage]);
const { get } = useEditor((root) => {
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialValueRef.current);
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
lastExternalValue.current = markdown;
onChange(markdown);
});
// Configure upload plugin
ctx.set(uploadConfig.key, {
uploader: async (files, schema) => {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file) continue;
// Only handle images
if (!file.type.includes('image')) continue;
images.push(file);
}
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
for (const image of images) {
try {
// Use the upload handler if provided
if (onUploadImageRef.current) {
const url = await onUploadImageRef.current(image);
if (url) {
const node = schema.nodes.image.createAndFill({
src: url,
alt: image.name,
});
if (node) nodes.push(node);
}
} else {
// Fallback to base64 if no upload handler
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(image);
});
const node = schema.nodes.image.createAndFill({
src: dataUrl,
alt: image.name,
});
if (node) nodes.push(node);
}
} catch (error) {
console.error('Failed to upload image:', error);
}
}
return nodes.filter((node): node is NonNullable<typeof node> => node !== null);
},
enableHtmlFileUploader: true,
uploadWidgetFactory: (pos, spec) => {
// Create a placeholder widget while uploading
const widgetEl = document.createElement('span');
widgetEl.className = 'milkdown-upload-placeholder';
widgetEl.textContent = 'Uploading...';
return Decoration.widget(pos, widgetEl, spec);
},
});
})
.use(commonmark)
.use(gfm)
.use(history)
.use(clipboard)
.use(listener)
.use(upload);
return editor;
});
// Store editor reference
useEffect(() => {
editorRef.current = get() ?? null;
}, [get]);
// Toggle `has-content` class on blur so CSS can hide the placeholder
// when the editor has real content (including trailing whitespace that
// ProseMirror collapses out of the DOM).
useEffect(() => {
const editor = get();
if (!editor) return;
let dom: HTMLElement;
try {
dom = editor.ctx.get(editorViewCtx).dom;
} catch {
return;
}
const check = () => {
const hasContent = !!lastExternalValue.current.replace(/\n/g, '');
dom.classList.toggle('has-content', hasContent);
};
// Set initial state
check();
dom.addEventListener('blur', check);
return () => dom.removeEventListener('blur', check);
}, [get]);
// Handle external value changes (e.g., loading a draft).
// In source mode, just keep lastExternalValue in sync so the guard works
// correctly when switching back. When not in source mode, push the new
// value into the Milkdown editor via replaceAll.
useEffect(() => {
if (sourceMode) {
// Track textarea changes so we don't needlessly replaceAll on switch-back
lastExternalValue.current = value;
return;
}
const editor = get();
if (editor && value !== lastExternalValue.current) {
try {
editor.action(replaceAll(value));
} catch {
// editorView may not be ready yet (e.g. first render); ignore
return;
}
lastExternalValue.current = value;
}
}, [value, get, sourceMode]);
// Handle link dialog open
const handleLinkButtonClick = useCallback(() => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state } = view;
const { from, to } = state.selection;
const selectedText = state.doc.textBetween(from, to);
// Store selection for later use
selectionRef.current = { from, to };
setSelectedTextForLink(selectedText);
setLinkDialogOpen(true);
} catch (error) {
console.error('Failed to get selection:', error);
}
}, [get]);
// Handle link insertion from dialog
const handleLinkSubmit = useCallback((text: string, url: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
// Create a link mark
const linkMark = schema.marks.link.create({ href: url });
// Create text node with link mark
const linkNode = schema.text(text, [linkMark]);
const tr = state.tr;
if (selectionRef.current) {
const { from, to } = selectionRef.current;
// Replace selection with linked text
tr.replaceWith(from, to, linkNode);
} else {
// Insert at current position
const { from } = state.selection;
tr.insert(from, linkNode);
}
dispatch(tr);
view.focus();
} catch (error) {
console.error('Failed to insert link:', error);
}
}, [get]);
// Handle image upload via file picker + ProseMirror insertion
const handleImageButtonClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleImageFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !onUploadImageRef.current) return;
const url = await onUploadImageRef.current(file);
if (!url) return;
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
const node = schema.nodes.image.createAndFill({ src: url, alt: file.name });
if (node) {
const { from } = state.selection;
dispatch(state.tr.insert(from, node));
view.focus();
}
} catch (error) {
console.error('Failed to insert image:', error);
}
// Reset so the same file can be re-selected
e.target.value = '';
}, [get]);
// Handle toolbar commands
const handleCommand = useCallback((command: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
switch (command) {
case 'toggleBold':
editor.action(callCommand(toggleStrongCommand.key));
break;
case 'toggleItalic':
editor.action(callCommand(toggleEmphasisCommand.key));
break;
case 'toggleStrikethrough':
editor.action(callCommand(toggleStrikethroughCommand.key));
break;
case 'toggleInlineCode':
editor.action(callCommand(toggleInlineCodeCommand.key));
break;
case 'heading1':
editor.action(callCommand(wrapInHeadingCommand.key, 1));
break;
case 'heading2':
editor.action(callCommand(wrapInHeadingCommand.key, 2));
break;
case 'heading3':
editor.action(callCommand(wrapInHeadingCommand.key, 3));
break;
case 'bulletList':
editor.action(callCommand(wrapInBulletListCommand.key));
break;
case 'orderedList':
editor.action(callCommand(wrapInOrderedListCommand.key));
break;
case 'blockquote':
editor.action(callCommand(wrapInBlockquoteCommand.key));
break;
case 'link':
handleLinkButtonClick();
return; // Don't refocus, dialog will handle it
case 'hr':
editor.action(callCommand(insertHrCommand.key));
break;
case 'paragraph':
editor.action(callCommand(turnIntoTextCommand.key));
break;
}
// Refocus the editor
view.focus();
} catch (error) {
console.error('Command failed:', error);
}
}, [get, handleLinkButtonClick]);
return (
<>
{showToolbar && (
<MilkdownToolbar
onCommand={handleCommand}
onImageUpload={onUploadImage ? handleImageButtonClick : undefined}
sourceMode={sourceMode}
onToggleSource={onToggleSource}
/>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageFileChange}
className="hidden"
/>
{sourceMode ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
className="w-full min-h-[250px] sm:min-h-[350px] p-3 bg-transparent font-mono text-sm resize-y outline-none"
placeholder={placeholder}
spellCheck={false}
/>
) : (
<div
className="milkdown-content"
onBlur={onBlur}
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
>
<Milkdown />
</div>
)}
<LinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
selectedText={selectedTextForLink}
onSubmit={handleLinkSubmit}
/>
</>
);
}
interface MilkdownEditorProps {
value: string;
onChange: (markdown: string) => void;
onBlur?: () => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
className?: string;
showToolbar?: boolean;
}
export function MilkdownEditor({ value, onChange, onBlur, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
const [sourceMode, setSourceMode] = useState(false);
return (
<div className={`milkdown-editor ${className || ''}`}>
<MilkdownProvider>
<MilkdownEditorInner
value={value}
onChange={onChange}
onBlur={onBlur}
onUploadImage={onUploadImage}
placeholder={placeholder}
showToolbar={showToolbar}
sourceMode={sourceMode}
onToggleSource={() => setSourceMode((s) => !s)}
/>
</MilkdownProvider>
</div>
);
}
+233
View File
@@ -0,0 +1,233 @@
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link,
Image,
Minus,
HelpCircle,
Eye,
EyeOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function MarkdownHelpPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-2">
<h4 className="font-medium text-sm">Markdown Quick Reference</h4>
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">bold</span></div>
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">italic</span></div>
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
<div className="flex justify-between"><span>- list item</span><span className="font-sans">* item</span></div>
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">1. item</span></div>
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">link</span></div>
<div className="flex justify-between"><span>![alt](url)</span><span className="font-sans">image</span></div>
<div className="flex justify-between"><span>&gt; quote</span><span className="font-sans border-l-2 pl-1">quote</span></div>
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">code</span></div>
</div>
<p className="text-xs text-muted-foreground pt-2 border-t">
Drag & drop or paste images to upload
</p>
</div>
</PopoverContent>
</Popover>
);
}
const hasPointerFine = typeof window !== 'undefined'
&& window.matchMedia('(pointer: fine)').matches;
interface ToolbarButtonProps {
icon: React.ReactNode;
label: string;
shortcut?: string;
onClick: () => void;
active?: boolean;
}
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
const button = (
<Button
variant="ghost"
size="icon"
onClick={onClick}
aria-label={label}
className={cn(
"h-8 w-8 text-muted-foreground hover:text-foreground",
active && "bg-muted text-foreground"
)}
>
{icon}
</Button>
);
if (!hasPointerFine) return button;
return (
<Tooltip>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent>
<span>{label}</span>
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
}
interface MilkdownToolbarProps {
onCommand: (command: string) => void;
onImageUpload?: () => void;
sourceMode?: boolean;
onToggleSource?: () => void;
className?: string;
}
export function MilkdownToolbar({ onCommand, onImageUpload, sourceMode, onToggleSource, className }: MilkdownToolbarProps) {
return (
<div className={cn(
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/95 backdrop-blur-sm flex-wrap sticky top-0 z-10 rounded-t-xl",
className
)}>
{!sourceMode && (
<>
{/* Text formatting */}
<ToolbarButton
icon={<Bold className="h-4 w-4" />}
label="Bold"
shortcut="Ctrl+B"
onClick={() => onCommand('toggleBold')}
/>
<ToolbarButton
icon={<Italic className="h-4 w-4" />}
label="Italic"
shortcut="Ctrl+I"
onClick={() => onCommand('toggleItalic')}
/>
<ToolbarButton
icon={<Strikethrough className="h-4 w-4" />}
label="Strikethrough"
onClick={() => onCommand('toggleStrikethrough')}
/>
<ToolbarButton
icon={<Code className="h-4 w-4" />}
label="Inline Code"
onClick={() => onCommand('toggleInlineCode')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Headings */}
<ToolbarButton
icon={<Heading1 className="h-4 w-4" />}
label="Heading 1"
onClick={() => onCommand('heading1')}
/>
<ToolbarButton
icon={<Heading2 className="h-4 w-4" />}
label="Heading 2"
onClick={() => onCommand('heading2')}
/>
<ToolbarButton
icon={<Heading3 className="h-4 w-4" />}
label="Heading 3"
onClick={() => onCommand('heading3')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Lists */}
<ToolbarButton
icon={<List className="h-4 w-4" />}
label="Bullet List"
onClick={() => onCommand('bulletList')}
/>
<ToolbarButton
icon={<ListOrdered className="h-4 w-4" />}
label="Numbered List"
onClick={() => onCommand('orderedList')}
/>
<ToolbarButton
icon={<Quote className="h-4 w-4" />}
label="Blockquote"
onClick={() => onCommand('blockquote')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Links and media */}
<ToolbarButton
icon={<Link className="h-4 w-4" />}
label="Insert Link"
onClick={() => onCommand('link')}
/>
{onImageUpload && (
<ToolbarButton
icon={<Image className="h-4 w-4" />}
label="Insert Image"
onClick={onImageUpload}
/>
)}
<ToolbarButton
icon={<Minus className="h-4 w-4" />}
label="Horizontal Rule"
onClick={() => onCommand('hr')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<MarkdownHelpPopover />
</>
)}
{sourceMode && (
<>
<span className="text-xs text-muted-foreground px-1.5">Markdown Source</span>
<span className="flex-1" />
</>
)}
{onToggleSource && (
<ToolbarButton
icon={sourceMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
label={sourceMode ? 'Rich text editor' : 'Markdown source'}
active={sourceMode}
onClick={onToggleSource}
/>
)}
</div>
);
}
+2 -2
View File
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
className
)}
{...props}
@@ -63,7 +63,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
+2 -2
View File
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
className
)}
{...props}
@@ -71,7 +71,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
-299
View File
@@ -1,299 +0,0 @@
import { useCallback } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { toast } from './useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
buildMigrationTags,
generatePetId10,
getCanonicalBlobbiD,
migratePetInHas,
updateBlobbonautTags,
parseBlobbiEvent,
parseStorageTags,
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
} from '@/lib/blobbi';
/**
* Result of a successful migration.
*/
export interface MigrationResult {
/** The new canonical d-tag */
canonicalD: string;
/** The published canonical Blobbi event */
event: NostrEvent;
/** The parsed canonical BlobbiCompanion */
companion: BlobbiCompanion;
/** The updated profile event */
profileEvent: NostrEvent;
/** The updated profile tags (canonical has, current_companion, etc.) */
profileTags: string[][];
/** The profile storage (unchanged during migration, but fresh from migrated profile) */
profileStorage: StorageItem[];
}
/**
* Options for the migration helper.
*/
export interface EnsureCanonicalOptions {
/** The companion to check/migrate */
companion: BlobbiCompanion;
/** The user's profile */
profile: BlobbonautProfile;
/** Callback to update the profile event in query cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Callback to update the companion event in query cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Callback to update localStorage selection if it was pointing to legacy d */
updateStoredSelectedD?: (newD: string) => void;
/** Callback to invalidate companion query */
invalidateCompanion?: () => void;
/** Callback to invalidate profile query */
invalidateProfile?: () => void;
}
/**
* Result of ensureCanonicalBlobbiBeforeAction.
*/
export interface EnsureCanonicalResult {
/** Whether the companion was migrated */
wasMigrated: boolean;
/** The canonical companion (either the original or the migrated one) */
companion: BlobbiCompanion;
/** The canonical event tags to use for the action */
allTags: string[][];
/** The event content to use */
content: string;
/**
* The latest profile tags to use for profile updates.
* IMPORTANT: Always use these instead of profile.allTags from hook closure
* to avoid restoring stale/legacy values after migration.
*/
profileAllTags: string[][];
/**
* The latest profile storage to use.
* Use this as the base for storage modifications.
*/
profileStorage: StorageItem[];
}
/**
* Hook providing centralized migration logic for Blobbi companions.
*
* This hook should be used by all action handlers to ensure legacy Blobbis
* are automatically migrated before any interaction.
*
* Usage:
* ```ts
* const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
*
* const handleFeed = async () => {
* const result = await ensureCanonicalBlobbiBeforeAction({
* companion,
* profile,
* updateProfileEvent,
* updateCompanionEvent,
* updateStoredSelectedD: setStoredSelectedD,
* });
*
* if (!result) return; // Migration failed
*
* // Continue with the action using result.companion and result.allTags
* const newTags = updateBlobbiTags(result.allTags, { ... });
* // ... publish event
* };
* ```
*/
export function useBlobbiMigration() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
/**
* Migrate a legacy Blobbi to canonical format.
*
* This function:
* 1. Generates a canonical d-tag
* 2. Ensures a seed exists (generates one if missing)
* 3. Preserves name, stage, stats, state, timestamps
* 4. Publishes a canonical 31124 event
* 5. Updates the Blobbonaut profile (kind 11125)
* 6. Updates local state (query cache, localStorage)
*/
const migrateLegacyBlobbi = useCallback(async (
options: EnsureCanonicalOptions
): Promise<MigrationResult | null> => {
const {
companion,
profile,
updateProfileEvent,
updateCompanionEvent,
updateStoredSelectedD,
invalidateCompanion,
invalidateProfile,
} = options;
if (!user?.pubkey) {
console.error('[Blobbi Migration] No user pubkey');
return null;
}
console.log('[Blobbi Migration] Starting migration for:', companion.d);
try {
// Generate new canonical d-tag
const newPetId = generatePetId10();
const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId);
// Build migration tags (preserves name, stage, stats, generates seed if missing)
const migrationTags = buildMigrationTags(companion.event, newPetId, user.pubkey);
console.log('[Blobbi Migration] Publishing canonical event with d:', canonicalD);
// Publish the canonical Blobbi state
const canonicalEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content || `${companion.name} is a ${companion.stage} Blobbi.`,
tags: migrationTags,
});
// Parse the new event to get the canonical companion
const canonicalCompanion = parseBlobbiEvent(canonicalEvent);
if (!canonicalCompanion) {
throw new Error('Failed to parse migrated event');
}
// Update profile: replace legacy d with canonical d in has[], update current_companion
const updatedHas = migratePetInHas(profile.has, companion.d, canonicalD);
const shouldUpdateCurrentCompanion = profile.currentCompanion === companion.d;
const profileUpdates: Record<string, string | string[]> = {
has: updatedHas,
};
if (shouldUpdateCurrentCompanion) {
profileUpdates.current_companion = canonicalD;
}
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
console.log('[Blobbi Migration] Publishing updated profile');
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
// Update query caches
updateProfileEvent(profileEvent);
updateCompanionEvent(canonicalEvent);
// Update localStorage selection if it was pointing to legacy d
if (updateStoredSelectedD) {
console.log('[Blobbi Migration] Updating localStorage selection:', canonicalD);
updateStoredSelectedD(canonicalD);
}
// Invalidate queries to refetch fresh data
invalidateCompanion?.();
invalidateProfile?.();
toast({
title: 'Pet upgraded!',
description: `${companion.name} has been migrated to the new format.`,
});
console.log('[Blobbi Migration] Migration complete:', {
legacyD: companion.d,
canonicalD,
});
// Parse storage from the migrated profile tags
// Storage itself doesn't change during migration, but we need fresh tags
const migratedStorage = parseStorageTags(profileTags);
return {
canonicalD,
event: canonicalEvent,
companion: canonicalCompanion,
profileEvent,
profileTags,
profileStorage: migratedStorage,
};
} catch (error) {
console.error('[Blobbi Migration] Migration failed:', error);
toast({
title: 'Migration failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
return null;
}
}, [user?.pubkey, publishEvent]);
/**
* Ensure a Blobbi is in canonical format before performing an action.
*
* If the companion is legacy, it will be migrated first.
* Returns the canonical companion to use for the action.
*
* Flow:
* 1. Check if Blobbi is legacy
* 2. If legacy: migrate Blobbi
* 3. Return the resolved canonical Blobbi
*
* All interaction handlers should call this before publishing events.
*/
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
options: EnsureCanonicalOptions
): Promise<EnsureCanonicalResult | null> => {
const { companion, profile } = options;
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
const migrationResult = await migrateLegacyBlobbi(options);
if (!migrationResult) {
// Migration failed, cannot proceed with action
return null;
}
// Return the canonical companion AND migrated profile context
// CRITICAL: Consumers must use profileAllTags instead of profile.allTags
// to avoid restoring stale/legacy values
return {
wasMigrated: true,
companion: migrationResult.companion,
allTags: migrationResult.event.tags,
content: migrationResult.event.content,
profileAllTags: migrationResult.profileTags,
profileStorage: migrationResult.profileStorage,
};
}
// Companion is already canonical, return profile as-is
return {
wasMigrated: false,
companion,
allTags: companion.allTags,
content: companion.event.content,
profileAllTags: profile.allTags,
profileStorage: profile.storage,
};
}, [migrateLegacyBlobbi]);
return {
/** Migrate a legacy Blobbi to canonical format */
migrateLegacyBlobbi,
/** Ensure a Blobbi is canonical before an action, migrating if necessary */
ensureCanonicalBlobbiBeforeAction,
};
}
-198
View File
@@ -1,198 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import {
KIND_BLOBBI_STATE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
} from '@/lib/blobbi';
/** Maximum number of d-tags per query chunk to avoid relay issues */
const CHUNK_SIZE = 20;
/**
* Split an array into chunks of a given size.
*/
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
/**
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
*
* Features:
* - Fetches ALL pets by d-tag list (no limit: 1)
* - Chunks large d-lists into multiple queries for relay compatibility
* - Keeps only the newest event per d-tag
* - Returns both a lookup record and array of companions
* - Provides invalidation and optimistic update helpers
*/
export function useBlobbisCollection(dList: string[] | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Create a stable query key based on sorted d-tags
const sortedDList = useMemo(() => {
if (!dList || dList.length === 0) return null;
return [...dList].sort();
}, [dList]);
const queryKeyDTags = sortedDList?.join(',') ?? '';
// Main query to fetch all companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
// Chunk the d-list for relay compatibility
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
// Query all chunks in parallel
const allEvents: NostrEvent[] = [];
for (const chunk of chunks) {
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
};
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
const events = await nostr.query([filter], { signal });
allEvents.push(...events);
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
}
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
// Filter to valid events
const validEvents = allEvents.filter(isValidBlobbiEvent);
console.log('[useBlobbisCollection] Valid events:', validEvents.length);
// Group events by d-tag and keep only the newest per d
const eventsByD = new Map<string, NostrEvent>();
for (const event of validEvents) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (!dTag) continue;
const existing = eventsByD.get(dTag);
if (!existing || event.created_at > existing.created_at) {
eventsByD.set(dTag, event);
}
}
// Parse all events into BlobbiCompanion objects
const companionsByD: Record<string, BlobbiCompanion> = {};
const companions: BlobbiCompanion[] = [];
for (const [dTag, event] of eventsByD) {
const parsed = parseBlobbiEvent(event);
if (parsed) {
companionsByD[dTag] = parsed;
companions.push(parsed);
}
}
console.log('[useBlobbisCollection] Parsed companions:', {
count: companions.length,
dTags: Object.keys(companionsByD),
});
return { companionsByD, companions };
},
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Helper to invalidate and refetch after publishing
const invalidate = useCallback(() => {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
});
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Update a single companion event in the query cache (optimistic update)
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
['blobbi-collection', user.pubkey, queryKeyDTags],
(prev) => {
if (!prev) {
return {
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
};
}
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
}
);
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
const companionsByD = query.data?.companionsByD ?? {};
const companions = query.data?.companions ?? [];
return {
/** Record of companions keyed by d-tag */
companionsByD,
/** Array of all companions (newest per d-tag) */
companions,
/** True only when query is loading and no data available */
isLoading: query.isLoading,
/** True when actively fetching */
isFetching: query.isFetching,
/** True when data is stale */
isStale: query.isStale,
/** Query error if any */
error: query.error,
/** Invalidate and refetch the collection */
invalidate,
/** Optimistically update a single companion in the cache */
updateCompanionEvent,
};
}
@@ -17,6 +17,7 @@ import { useNostrPublish } from './useNostrPublish';
import {
KIND_BLOBBONAUT_PROFILE,
profileNeedsPettingLevelNormalization,
profileNeedsOnboardingTagMigration,
buildNormalizedProfileTags,
isLegacyBlobbonautKind,
type BlobbonautProfile,
@@ -55,9 +56,10 @@ export function useBlobbonautProfileNormalization({
// Check what normalization is needed
const needsTagNormalization = profileNeedsPettingLevelNormalization(profile);
const needsKindMigration = isLegacyBlobbonautKind(profile.event);
const needsOnboardingMigration = profileNeedsOnboardingTagMigration(profile);
// If no normalization needed, mark as seen and return
if (!needsTagNormalization && !needsKindMigration) {
if (!needsTagNormalization && !needsKindMigration && !needsOnboardingMigration) {
normalizedEventIds.current.add(profile.event.id);
return;
}
@@ -68,6 +70,7 @@ export function useBlobbonautProfileNormalization({
const reasons: string[] = [];
if (needsTagNormalization) reasons.push('missing pettingLevel');
if (needsKindMigration) reasons.push('legacy kind 31125 → 11125');
if (needsOnboardingMigration) reasons.push('onboarding_done → blobbi_onboarding_done');
console.log(`[ProfileNormalization] Profile needs normalization: ${reasons.join(', ')}`);
+152
View File
@@ -0,0 +1,152 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { type ArticleFields } from '@/lib/articleHelpers';
/** Kind 31234 — NIP-37 Draft Wrap. */
const DRAFT_WRAP_KIND = 31234;
/** The inner draft kind we're wrapping. */
const ARTICLE_KIND = 30023;
export interface Draft extends ArticleFields {
id: string;
updatedAt: number;
eventId?: string;
}
type DraftData = ArticleFields;
/** Build an unsigned kind-30023 event object from draft data. */
function buildInnerDraftEvent(draft: DraftData): Record<string, unknown> {
const tags: string[][] = [
['d', draft.slug],
['title', draft.title],
];
if (draft.summary) tags.push(['summary', draft.summary]);
if (draft.image) tags.push(['image', draft.image]);
draft.tags.forEach(tag => tags.push(['t', tag]));
return {
kind: ARTICLE_KIND,
content: draft.content,
tags,
};
}
/** Parse a decrypted inner draft event back into a Draft. */
function parseDraftPayload(inner: Record<string, unknown>, wrapEvent: NostrEvent): Draft | null {
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
return {
id: wrapEvent.id,
eventId: wrapEvent.id,
title: getTag('title'),
summary: getTag('summary'),
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
updatedAt: wrapEvent.created_at * 1000,
};
}
export function useDrafts() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
// Query and decrypt drafts from relay
const query = useQuery<Draft[]>({
queryKey: ['drafts', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !user.signer.nip44) return [];
const events = await nostr.query(
[{ kinds: [DRAFT_WRAP_KIND], authors: [user.pubkey], '#k': [String(ARTICLE_KIND)], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
const drafts: Draft[] = [];
for (const event of events) {
// Blank content means deleted
if (!event.content.trim()) continue;
try {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const draft = parseDraftPayload(inner, event);
if (draft && draft.content.trim()) drafts.push(draft);
} catch {
// Skip events that fail to decrypt or parse
continue;
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
},
enabled: !!user?.pubkey && !!user?.signer.nip44,
staleTime: 30 * 1000,
});
// Save draft: encrypt inner event and publish as kind 31234
const saveMutation = useMutation({
mutationFn: async (draft: DraftData) => {
if (!user?.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
const inner = buildInnerDraftEvent(draft);
const plaintext = JSON.stringify(inner);
const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext);
return publishEvent({
kind: DRAFT_WRAP_KIND,
content: encrypted,
tags: [
['d', draft.slug],
['k', String(ARTICLE_KIND)],
],
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drafts', user?.pubkey] });
},
});
// Delete draft (publish kind 5 deletion event)
const deleteMutation = useMutation({
mutationFn: async (slug: string) => {
if (!user) throw new Error('User is not logged in');
const event = await publishEvent({
kind: 5,
content: '',
tags: [['a', `${DRAFT_WRAP_KIND}:${user.pubkey}:${slug}`]],
});
return { event, slug };
},
onSuccess: (data) => {
queryClient.setQueryData(['drafts', user?.pubkey], (oldData: Draft[] | undefined) => {
if (!oldData) return [];
return oldData.filter(d => d.slug !== data?.slug);
});
},
});
return {
drafts: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
saveDraft: saveMutation.mutateAsync,
isSaving: saveMutation.isPending,
deleteDraft: deleteMutation.mutateAsync,
isDeleting: deleteMutation.isPending,
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
/** Kinds whose canonical home is the Zapstore relay. */
const ZAPSTORE_KINDS = [32267, 30063];
const ZAPSTORE_KINDS = [32267, 30063, 3063];
/**
* Extract write relay URLs from a NIP-65 (kind 10002) relay list event.
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
/**
* Detects whether the virtual keyboard is likely open on mobile devices.
*
* Uses the Visual Viewport API to compare the visible viewport height against
* the full layout viewport. When the keyboard slides up, `visualViewport.height`
* shrinks while `window.innerHeight` stays the same (or changes minimally).
*
* A threshold of 0.75 (75%) is used — if the visible area is less than 75% of
* the layout viewport, we assume the keyboard is open.
*/
export function useKeyboardVisible(): boolean {
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const THRESHOLD = 0.75;
const check = () => {
const ratio = vv.height / window.innerHeight;
const visible = ratio < THRESHOLD;
setIsKeyboardVisible(visible);
};
vv.addEventListener('resize', check);
check();
return () => {
vv.removeEventListener('resize', check);
};
}, []);
return isKeyboardVisible;
}
+9 -1
View File
@@ -419,11 +419,19 @@ export function useNotifications(): NotificationData {
// match because useHasUnreadNotifications uses a 4-element key
// ['notifications-unread', pubkey, kindsKey, authorsKey] and setQueryData
// requires an exact match (which silently misses the real cache entry).
//
// NOTE: We intentionally do NOT call invalidateQueries here. Invalidation
// triggers an immediate refetch whose queryFn closure may still hold the
// old notificationsCursor (from a render before the settings cache update
// propagates). That stale refetch re-queries the relay with the old
// `since` value, finds the same "unread" events, returns `true`, and
// overwrites the `false` we just set — causing the dot to reappear.
// The 60-second poll (or real-time subscription) will naturally
// re-evaluate once the cursor has fully propagated.
queryClient.setQueriesData<boolean>(
{ queryKey: ['notifications-unread', user.pubkey] },
false,
);
queryClient.invalidateQueries({ queryKey: ['notifications-unread', user.pubkey] });
} catch (error) {
console.error('Failed to mark notifications as read:', error);
optimisticCursor.current = null;
-122
View File
@@ -1,122 +0,0 @@
/**
* Hook for projecting Blobbi decay state in the UI.
*
* This hook provides a local projection of decay without publishing events.
* It recalculates every 60 seconds while the component is mounted.
*
* The projected state is for UI display only. Actual mutations must
* recalculate from the persisted state before publishing.
*
* @see docs/blobbi/decay-system.md
*/
import { useState, useEffect, useMemo } from 'react';
import type { BlobbiCompanion, BlobbiStats } from '@/lib/blobbi';
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/lib/blobbi-decay';
/** UI refresh interval in milliseconds (60 seconds) */
const UI_REFRESH_INTERVAL_MS = 60_000;
/**
* Projected Blobbi state for UI display.
*/
export interface ProjectedBlobbiState {
/** Stats after applying projected decay */
stats: BlobbiStats;
/** Visible stats for the current stage with status indicators */
visibleStats: Array<{
stat: keyof BlobbiStats;
value: number;
status: 'critical' | 'warning' | 'normal';
}>;
/** Time elapsed since last decay (seconds) */
elapsedSeconds: number;
/** Timestamp of the projection calculation */
projectedAt: number;
/** Whether this is a fresh projection (recalculated this render) */
isFresh: boolean;
}
/**
* Hook to get a projected Blobbi state with decay applied.
*
* Features:
* - Immediately calculates projected state on mount/companion change
* - Recalculates every 60 seconds while mounted
* - Pure calculation - does not publish any events
* - Returns both full stats and stage-appropriate visible stats
*
* @param companion - The persisted Blobbi companion (source of truth)
* @returns Projected state with decay applied, or null if no companion
*/
export function useProjectedBlobbiState(
companion: BlobbiCompanion | null
): ProjectedBlobbiState | null {
// Track when we last recalculated
const [refreshTick, setRefreshTick] = useState(0);
// Set up 60-second refresh interval
useEffect(() => {
if (!companion) return;
const interval = setInterval(() => {
setRefreshTick(t => t + 1);
}, UI_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [companion]);
// Calculate projected state
const projectedState = useMemo((): ProjectedBlobbiState | null => {
if (!companion) return null;
const now = Math.floor(Date.now() / 1000);
// Apply decay from persisted state
const decayResult: DecayResult = applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now,
});
// Get visible stats for the stage
const visibleStats = getVisibleStatsWithValues(companion.stage, decayResult.stats);
return {
stats: decayResult.stats,
visibleStats,
elapsedSeconds: decayResult.elapsedSeconds,
projectedAt: now,
isFresh: true,
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTick triggers recalculation
}, [companion, refreshTick]);
return projectedState;
}
/**
* Calculate projected decay for a companion at a specific timestamp.
*
* This is a utility function for use outside of React components,
* such as in mutation handlers before publishing.
*
* @param companion - The persisted Blobbi companion
* @param now - Unix timestamp to calculate decay to (defaults to current time)
* @returns Decay result with updated stats
*/
export function calculateProjectedDecay(
companion: BlobbiCompanion,
now?: number
): DecayResult {
return applyBlobbiDecay({
stage: companion.stage,
state: companion.state,
stats: companion.stats,
lastDecayAt: companion.lastDecayAt,
now: now ?? Math.floor(Date.now() / 1000),
});
}
+56
View File
@@ -0,0 +1,56 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { parseArticleEvent, type ArticleFields } from '@/lib/articleHelpers';
export interface PublishedArticle extends ArticleFields {
id: string;
eventId: string;
publishedAt: number;
updatedAt: number;
}
function eventToArticle(event: NostrEvent): PublishedArticle {
const parsed = parseArticleEvent(event);
return {
...parsed,
id: event.id,
eventId: event.id,
updatedAt: event.created_at * 1000,
};
}
export function usePublishedArticles() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const query = useQuery<PublishedArticle[]>({
queryKey: ['published-articles', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
return [];
}
const events = await nostr.query(
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events
.filter(e => e.content.trim().length > 0)
.map(eventToArticle)
.sort((a, b) => b.publishedAt - a.publishedAt);
},
enabled: !!user?.pubkey,
staleTime: 30 * 1000,
});
return {
articles: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+158
View File
@@ -494,3 +494,161 @@
}
}
/* ── Milkdown Editor Styles ─────────────────────────────────────────────────── */
.milkdown-editor .milkdown {
@apply outline-none;
}
.milkdown-editor .editor {
@apply outline-none min-h-[400px] p-3;
font-size: 1.125rem;
line-height: 1.75;
}
.milkdown-editor .ProseMirror {
@apply outline-none min-h-[400px];
}
.milkdown-editor .ProseMirror:focus {
@apply outline-none;
}
/* Headings */
.milkdown-editor h1 {
@apply text-3xl font-bold mt-6 mb-4;
}
.milkdown-editor h2 {
@apply text-2xl font-semibold mt-5 mb-3;
}
.milkdown-editor h3 {
@apply text-xl font-semibold mt-4 mb-2;
}
.milkdown-editor h4 {
@apply text-lg font-medium mt-3 mb-2;
}
/* Inline styles */
.milkdown-editor strong {
@apply font-bold;
}
.milkdown-editor em {
@apply italic;
}
.milkdown-editor del {
@apply line-through text-muted-foreground;
}
.milkdown-editor code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
}
/* Block elements */
.milkdown-editor p {
@apply my-1.5;
}
.milkdown-editor blockquote {
@apply border-l-4 border-primary/40 pl-4 my-4 italic text-muted-foreground;
}
.milkdown-editor pre {
@apply bg-muted rounded-lg p-4 my-4 overflow-x-auto;
}
.milkdown-editor pre code {
@apply bg-transparent p-0 font-mono;
font-size: 0.875rem;
}
/* Lists */
.milkdown-editor ul {
@apply list-disc list-inside my-3 space-y-1;
}
.milkdown-editor ol {
@apply list-decimal list-inside my-3 space-y-1;
}
.milkdown-editor li {
@apply pl-1;
}
.milkdown-editor li p {
@apply inline my-0;
}
/* Task lists (GFM) */
.milkdown-editor ul.task-list {
@apply list-none pl-0;
}
.milkdown-editor li.task-list-item {
@apply flex items-start gap-2 pl-0;
}
.milkdown-editor li.task-list-item input[type="checkbox"] {
@apply mt-1.5 h-4 w-4 rounded border-border;
}
/* Links */
.milkdown-editor a {
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
}
/* Horizontal rule */
.milkdown-editor hr {
@apply my-6 border-border;
}
/* Images */
.milkdown-editor img {
@apply max-w-full h-auto rounded-lg my-4;
}
/* Tables (GFM) */
.milkdown-editor table {
@apply w-full border-collapse my-4;
}
.milkdown-editor th,
.milkdown-editor td {
@apply border border-border px-3 py-2 text-left;
}
.milkdown-editor th {
@apply bg-muted font-semibold;
}
.milkdown-editor tr:nth-child(even) {
@apply bg-muted/30;
}
/* Upload placeholder */
.milkdown-upload-placeholder {
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md text-sm text-muted-foreground;
}
.milkdown-upload-placeholder::before {
content: '';
@apply w-4 h-4 border-2 border-muted-foreground/30 border-t-primary rounded-full animate-spin;
}
/* Placeholder — only when unfocused AND content is empty. */
.milkdown-editor .ProseMirror:not(:focus):not(.has-content) > p:first-child::before {
@apply text-muted-foreground pointer-events-none float-left h-0;
content: var(--ph, '');
}
/* Milkdown content area */
.milkdown-editor .milkdown-content .ProseMirror {
@apply min-h-[350px];
}
+33
View File
@@ -0,0 +1,33 @@
import type { NostrEvent } from '@nostrify/nostrify';
/** Fields shared by drafts and published articles. */
export interface ArticleFields {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
/**
* Extract common article fields from a Nostr event's tags + content.
* Works for kind 30023 (published) events and the inner event of NIP-37 draft wraps.
*/
export function parseArticleEvent(event: NostrEvent): ArticleFields & { publishedAt: number } {
const getTag = (name: string) => event.tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => event.tags.filter(t => t[0] === name).map(t => t[1]);
const publishedAtTag = getTag('published_at');
const publishedAt = publishedAtTag ? parseInt(publishedAtTag) * 1000 : event.created_at * 1000;
return {
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
publishedAt,
};
}
-500
View File
@@ -1,500 +0,0 @@
/**
* Blobbi Decay System
*
* This module implements the continuous proportional decay system for Blobbi stats.
*
* Key principles:
* - Pure, deterministic calculation based on elapsed time
* - Floored stat changes before application
* - Stats clamped to 0-100 range
* - Stage-specific decay rates and health modifiers
* - Persisted state is the source of truth
*
* @see docs/blobbi/decay-system.md for full documentation
*/
import type { BlobbiStage, BlobbiState, BlobbiStats } from './blobbi';
import { STAT_MIN, STAT_MAX } from './blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of applying decay to a Blobbi.
* Contains updated stats and metadata about the calculation.
*/
export interface DecayResult {
/** Updated stats after decay (clamped to 0-100) */
stats: BlobbiStats;
/** Elapsed time in seconds that was used for decay calculation */
elapsedSeconds: number;
/** The timestamp that should be set as the new last_decay_at */
newDecayTimestamp: number;
}
/**
* Input parameters for decay calculation.
* Uses the persisted Blobbi state as source of truth.
*/
export interface DecayInput {
/** Current life stage */
stage: BlobbiStage;
/** Current activity state (awake/sleeping) */
state: BlobbiState;
/** Current stats from persisted state */
stats: Partial<BlobbiStats>;
/** Unix timestamp of last decay application */
lastDecayAt: number | undefined;
/** Current unix timestamp (defaults to now) */
now?: number;
}
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
/**
* Baby stage decay rates (per hour).
*
* Design goal: Needs attention every 3-5 hours.
*/
const BABY_DECAY = {
hunger: -7.0,
happiness: -4.0,
hygiene: -5.0,
energy: {
awake: -8.0,
sleeping: 6.0, // Regeneration
},
health: {
base: -0.75,
hungerBelow70: -0.75,
hungerBelow40: -1.25,
hygieneBelow70: -0.75,
hygieneBelow40: -1.25,
energyBelow50: -0.5,
energyBelow25: -1.0,
happinessBelow50: -0.5,
happinessBelow25: -1.0,
// Regeneration when all stats are >= 80
regenThreshold: 80,
regenRate: 1.5,
},
} as const;
/**
* Adult stage decay rates (per hour).
*
* Design goal: Needs attention every 5-7 hours.
*/
const ADULT_DECAY = {
hunger: -4.5,
happiness: -2.5,
hygiene: -3.5,
energy: {
awake: -5.0,
sleeping: 5.0, // Regeneration
},
health: {
base: -0.4,
hungerBelow60: -0.5,
hungerBelow30: -1.0,
hygieneBelow60: -0.5,
hygieneBelow30: -1.0,
energyBelow40: -0.4,
energyBelow20: -0.8,
happinessBelow40: -0.4,
happinessBelow20: -0.8,
// Regeneration when all stats are >= 80
regenThreshold: 80,
regenRate: 1.0,
},
} as const;
// ─── Constants: Warning Thresholds ────────────────────────────────────────────
/**
* Warning thresholds by stage.
* Warning = stat below this value indicates the Blobbi needs attention.
*/
export const WARNING_THRESHOLDS = {
egg: {
hygiene: 75,
health: 75,
happiness: 75,
},
baby: {
hunger: 65,
happiness: 65,
hygiene: 65,
energy: 65,
health: 65,
},
adult: {
hunger: 60,
happiness: 60,
hygiene: 60,
energy: 60,
health: 60,
},
} as const;
/**
* Critical thresholds by stage.
* Critical = stat below this value indicates urgent attention needed.
*/
export const CRITICAL_THRESHOLDS = {
egg: {
hygiene: 45,
health: 45,
happiness: 45,
},
baby: {
hunger: 35,
happiness: 35,
hygiene: 35,
energy: 25,
health: 35,
},
adult: {
hunger: 30,
happiness: 30,
hygiene: 30,
energy: 20,
health: 30,
},
} as const;
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Clamp a value to the STAT_MIN-STAT_MAX range (1-100).
* Stats can never reach true zero - minimum is always 1.
*/
function clamp(value: number): number {
return Math.max(STAT_MIN, Math.min(STAT_MAX, value));
}
/**
* Get stat value with fallback to 100 (full).
*/
function getStat(stats: Partial<BlobbiStats>, key: keyof BlobbiStats): number {
return stats[key] ?? 100;
}
/**
* Convert hours to the elapsed time unit for calculation.
* @param hours - Elapsed hours
* @returns Rate multiplier for the elapsed time
*/
function hoursFromSeconds(seconds: number): number {
return seconds / 3600;
}
// ─── Stage-Specific Decay Calculators ─────────────────────────────────────────
/**
* Calculate egg stage decay.
*
* Eggs only decay hygiene, health, and happiness.
* Hunger and energy are fixed at 100.
*/
function calculateEggDecay(
stats: Partial<BlobbiStats>,
_elapsedHours: number
): BlobbiStats {
// Eggs do not decay — all stats remain fixed until hatching.
return {
hunger: 100,
energy: 100,
hygiene: getStat(stats, 'hygiene'),
health: getStat(stats, 'health'),
happiness: getStat(stats, 'happiness'),
};
}
/**
* Calculate baby stage decay.
*/
function calculateBabyDecay(
stats: Partial<BlobbiStats>,
state: BlobbiState,
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Get current values
let hunger = getStat(stats, 'hunger');
let happiness = getStat(stats, 'happiness');
let hygiene = getStat(stats, 'hygiene');
let energy = getStat(stats, 'energy');
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = BABY_DECAY.hunger * elapsedHours;
const happinessDelta = BABY_DECAY.happiness * elapsedHours;
const hygieneDelta = BABY_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + Math.floor(hungerDelta));
happiness = clamp(happiness + Math.floor(happinessDelta));
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
energy = clamp(energy + Math.floor(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = BABY_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * elapsedHours;
if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * elapsedHours;
// Hygiene penalties
if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * elapsedHours;
if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * elapsedHours;
// Energy penalties
if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * elapsedHours;
if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * elapsedHours;
// Happiness penalties
if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * elapsedHours;
if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * elapsedHours;
// Health regeneration (all stats >= 80)
const threshold = BABY_DECAY.health.regenThreshold;
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + Math.floor(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
/**
* Calculate adult stage decay.
*/
function calculateAdultDecay(
stats: Partial<BlobbiStats>,
state: BlobbiState,
elapsedHours: number
): BlobbiStats {
const isSleeping = state === 'sleeping';
// Get current values
let hunger = getStat(stats, 'hunger');
let happiness = getStat(stats, 'happiness');
let hygiene = getStat(stats, 'hygiene');
let energy = getStat(stats, 'energy');
let health = getStat(stats, 'health');
// Calculate basic stat decay/regen
const hungerDelta = ADULT_DECAY.hunger * elapsedHours;
const happinessDelta = ADULT_DECAY.happiness * elapsedHours;
const hygieneDelta = ADULT_DECAY.hygiene * elapsedHours;
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
// Apply basic deltas
hunger = clamp(hunger + Math.floor(hungerDelta));
happiness = clamp(happiness + Math.floor(happinessDelta));
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
energy = clamp(energy + Math.floor(energyDelta));
// Calculate health (complex conditional decay + possible regen)
let healthDelta = ADULT_DECAY.health.base * elapsedHours;
// Hunger penalties
if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * elapsedHours;
if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * elapsedHours;
// Hygiene penalties
if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * elapsedHours;
if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * elapsedHours;
// Energy penalties
if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * elapsedHours;
if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * elapsedHours;
// Happiness penalties
if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * elapsedHours;
if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * elapsedHours;
// Health regeneration (all stats >= 80)
const threshold = ADULT_DECAY.health.regenThreshold;
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
healthDelta += ADULT_DECAY.health.regenRate * elapsedHours;
}
health = clamp(health + Math.floor(healthDelta));
return { hunger, happiness, hygiene, energy, health };
}
// ─── Main Decay Function ──────────────────────────────────────────────────────
/**
* Apply decay to a Blobbi based on elapsed time since last decay.
*
* This is a pure, deterministic function that:
* 1. Calculates elapsed time from lastDecayAt to now
* 2. Applies stage-specific decay rates
* 3. Floors all stat deltas before application
* 4. Clamps final stats to 0-100 range
* 5. Returns updated stats without side effects
*
* @param input - Decay input parameters from persisted state
* @returns DecayResult with updated stats and new decay timestamp
*/
export function applyBlobbiDecay(input: DecayInput): DecayResult {
const now = input.now ?? Math.floor(Date.now() / 1000);
const lastDecayAt = input.lastDecayAt ?? now;
// Calculate elapsed time
const elapsedSeconds = Math.max(0, now - lastDecayAt);
const elapsedHours = hoursFromSeconds(elapsedSeconds);
// If no time has passed, return current stats unchanged
if (elapsedSeconds === 0) {
return {
stats: {
hunger: getStat(input.stats, 'hunger'),
happiness: getStat(input.stats, 'happiness'),
health: getStat(input.stats, 'health'),
hygiene: getStat(input.stats, 'hygiene'),
energy: getStat(input.stats, 'energy'),
},
elapsedSeconds: 0,
newDecayTimestamp: now,
};
}
// Apply stage-specific decay
let newStats: BlobbiStats;
switch (input.stage) {
case 'egg':
newStats = calculateEggDecay(input.stats, elapsedHours);
break;
case 'baby':
newStats = calculateBabyDecay(input.stats, input.state, elapsedHours);
break;
case 'adult':
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
break;
default:
// Fallback to adult decay for unknown stages
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
}
return {
stats: newStats,
elapsedSeconds,
newDecayTimestamp: now,
};
}
// ─── Threshold Checkers ───────────────────────────────────────────────────────
/**
* Check if a stat is at warning level for the given stage.
*/
export function isStatAtWarning(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): boolean {
const thresholds = WARNING_THRESHOLDS[stage];
const threshold = (thresholds as Record<string, number>)[stat];
if (threshold === undefined) return false;
return value < threshold;
}
/**
* Check if a stat is at critical level for the given stage.
*/
export function isStatAtCritical(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): boolean {
const thresholds = CRITICAL_THRESHOLDS[stage];
const threshold = (thresholds as Record<string, number>)[stat];
if (threshold === undefined) return false;
return value < threshold;
}
/**
* Get the status level for a stat.
* @returns 'critical' | 'warning' | 'normal'
*/
export function getStatStatus(
stage: BlobbiStage,
stat: keyof BlobbiStats,
value: number
): 'critical' | 'warning' | 'normal' {
if (isStatAtCritical(stage, stat, value)) return 'critical';
if (isStatAtWarning(stage, stat, value)) return 'warning';
return 'normal';
}
/**
* Get all stats that are at warning or critical level.
*/
export function getStatsNeedingAttention(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> {
const results: Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> = [];
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
// For eggs, only check relevant stats
const relevantStats = stage === 'egg'
? ['health', 'hygiene', 'happiness'] as (keyof BlobbiStats)[]
: statKeys;
for (const stat of relevantStats) {
const value = stats[stat] ?? 100;
const status = getStatStatus(stage, stat, value);
if (status !== 'normal') {
results.push({ stat, value, status });
}
}
return results;
}
// ─── Visible Stats Helper ─────────────────────────────────────────────────────
/**
* Visibility threshold: stats at or above this value are hidden in the UI.
* Only stats below this threshold are displayed.
*/
export const STAT_VISIBILITY_THRESHOLD = 70;
/**
* Get the stats that should be visible for a given stage.
* Eggs only show health, hygiene, happiness.
* Baby/adult show all stats.
*/
export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] {
if (stage === 'egg') {
return ['health', 'hygiene', 'happiness'];
}
return ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
}
/**
* Get visible stats with their values for display.
* Stats at or above STAT_VISIBILITY_THRESHOLD are filtered out.
*/
export function getVisibleStatsWithValues(
stage: BlobbiStage,
stats: Partial<BlobbiStats>
): Array<{ stat: keyof BlobbiStats; value: number; status: 'critical' | 'warning' | 'normal' }> {
const visibleStats = getVisibleStats(stage);
return visibleStats
.map(stat => ({
stat,
value: stats[stat] ?? 100,
status: getStatStatus(stage, stat, stats[stat] ?? 100),
}))
.filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD);
}
-156
View File
@@ -1,156 +0,0 @@
/**
* Blobbi → EggGraphic Adapter
*
* This module provides a translation layer between the Blobbi domain model
* and the portable EggGraphic visual module.
*
* PURPOSE:
* - Keep the game/domain visual model decoupled from EggGraphic internals
* - Provide explicit mappings between vocabularies
* - Act as the single translation boundary for visual rendering
*
* USAGE:
* ```ts
* const eggVisual = toEggGraphicVisualBlobbi(companion);
* // Pass eggVisual to EggGraphic component
* ```
*/
import type { EggVisualBlobbi } from '@/blobbi/egg';
import {
type BlobbiCompanion,
type BlobbiPattern,
type BlobbiSpecialMark,
type BlobbiStage,
getTagValue,
} from './blobbi';
// ─── Egg Module Types (derived from EggVisualBlobbi) ──────────────────────────
/** Life stage values accepted by EggGraphic */
type EggLifeStage = NonNullable<EggVisualBlobbi['lifeStage']>;
/** Pattern values accepted by EggGraphic */
type EggPattern = NonNullable<EggVisualBlobbi['pattern']>;
/** Special mark values accepted by EggGraphic */
type EggSpecialMark = NonNullable<EggVisualBlobbi['specialMark']>;
/** Theme variant values accepted by EggGraphic */
type EggThemeVariant = NonNullable<EggVisualBlobbi['themeVariant']>;
// ─── Mapping Tables ───────────────────────────────────────────────────────────
/**
* Maps Blobbi pattern values to EggGraphic pattern values.
* Explicit mapping allows vocabularies to diverge in the future.
*/
const PATTERN_MAP: Record<BlobbiPattern, EggPattern> = {
'solid': 'solid',
'spotted': 'spotted',
'striped': 'striped',
'gradient': 'gradient',
};
/**
* Maps Blobbi special mark values to EggGraphic special mark values.
*/
const SPECIAL_MARK_MAP: Record<BlobbiSpecialMark, EggSpecialMark> = {
'none': 'none',
'star': 'star',
'heart': 'heart',
'sparkle': 'sparkle',
'blush': 'blush',
};
/**
* Maps Blobbi stage values to EggGraphic life stage values.
*/
const LIFE_STAGE_MAP: Record<BlobbiStage, EggLifeStage> = {
'egg': 'egg',
'baby': 'baby',
'adult': 'adult',
};
// ─── Fallback Values ──────────────────────────────────────────────────────────
const DEFAULT_PATTERN: EggPattern = 'solid';
const DEFAULT_SPECIAL_MARK: EggSpecialMark = 'none';
const DEFAULT_LIFE_STAGE: EggLifeStage = 'egg';
const DEFAULT_THEME_VARIANT: EggThemeVariant = 'default';
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Extract crossover app identifier from companion tags.
*/
function extractCrossoverApp(allTags: string[][]): string | undefined {
return getTagValue(allTags, 'crossover_app');
}
// ─── Main Adapter Function ────────────────────────────────────────────────────
/**
* Convert a BlobbiCompanion to EggVisualBlobbi for rendering.
*
* This is the TRANSLATION BOUNDARY between the Blobbi domain model
* and the EggGraphic visual module.
*
* The adapter:
* - Maps vocabulary values through explicit mapping tables
* - Passes through full tags for EggGraphic metadata lookups
* - Provides safe fallbacks for any missing/invalid data
* - Does NOT leak app-specific assumptions into EggGraphic
*
* @param companion - The parsed BlobbiCompanion from parseBlobbiEvent
* @param themeVariant - Optional theme variant override
* @returns Visual data compatible with EggVisualBlobbi
*/
export function toEggGraphicVisualBlobbi(
companion: BlobbiCompanion,
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
): EggVisualBlobbi {
const { visualTraits, stage, allTags } = companion;
return {
// Colors pass through directly (already CSS hex values)
baseColor: visualTraits.baseColor,
secondaryColor: visualTraits.secondaryColor,
// Mapped through explicit tables with fallbacks
pattern: PATTERN_MAP[visualTraits.pattern] ?? DEFAULT_PATTERN,
specialMark: SPECIAL_MARK_MAP[visualTraits.specialMark] ?? DEFAULT_SPECIAL_MARK,
lifeStage: LIFE_STAGE_MAP[stage] ?? DEFAULT_LIFE_STAGE,
// Theme variant
themeVariant,
// Pass through full tags for EggGraphic metadata lookups
tags: allTags,
// Extracted convenience values
crossoverApp: extractCrossoverApp(allTags),
// NOTE: We intentionally do NOT pass companion.name as title here.
// The EggGraphic 'title' field is for special designations (e.g., "Divine"),
// not the pet's name. The pet name is displayed separately by the parent component.
};
}
/**
* Check if two EggVisualBlobbi configurations are visually equivalent.
* Useful for memoization and avoiding unnecessary re-renders.
*/
export function areEggGraphicVisualsEqual(
a: EggVisualBlobbi,
b: EggVisualBlobbi
): boolean {
return (
a.baseColor === b.baseColor &&
a.secondaryColor === b.secondaryColor &&
a.pattern === b.pattern &&
a.specialMark === b.specialMark &&
a.lifeStage === b.lifeStage &&
a.themeVariant === b.themeVariant
);
}
File diff suppressed because it is too large Load Diff
-1568
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -11,6 +11,14 @@ interface ChangelogEntry {
}[];
}
/** Apply basic typographic transformations to a changelog item string. */
function prettify(text: string): string {
return text
.replace(/ -- /g, ' \u2014 ') // space-dash-dash-space → em dash
.replace(/(\w)--(\w)/g, '$1\u2013$2') // word--word → en dash
.replace(/ (\S+)$/, '\u00A0$1'); // prevent orphaned last word
}
/**
* Parse a Keep a Changelog formatted markdown string into structured data.
* @see https://keepachangelog.com/
@@ -43,10 +51,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
if (itemMatch && current) {
const section = current.sections[current.sections.length - 1];
if (section) {
section.items.push(itemMatch[1]);
section.items.push(prettify(itemMatch[1]));
} else {
// Item without a category heading — treat as "Changed"
current.sections.push({ category: 'Changed', items: [itemMatch[1]] });
current.sections.push({ category: 'Changed', items: [prettify(itemMatch[1])] });
}
continue;
}
@@ -58,10 +66,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
const section = current.sections[current.sections.length - 1];
if (section) {
// Append to last item or add new item
section.items.push(trimmed);
section.items.push(prettify(trimmed));
} else {
// Freeform text under version with no category — store in a generic section
current.sections.push({ category: 'Changed', items: [trimmed] });
current.sections.push({ category: 'Changed', items: [prettify(trimmed)] });
}
}
}
+4 -3
View File
@@ -128,8 +128,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
route: 'articles',
addressable: true,
section: 'feed',
blurb: 'Blog posts, essays, and guides. Write and publish from a dedicated editor.',
sites: [{ url: 'https://inkwell.shakespeare.wtf' }],
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
},
// Media
{
@@ -479,7 +478,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
id: 'development',
showKey: 'showDevelopment',
feedKey: 'feedIncludeDevelopment',
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267],
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 30063, 31990],
label: 'Development',
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
route: 'development',
@@ -548,7 +547,9 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
30008: 'profile badges',
30817: 'repository issue',
32267: 'app',
31990: 'app',
30063: 'release',
3063: 'asset',
};
/**
+76
View File
@@ -0,0 +1,76 @@
import type { Draft } from '@/hooks/useDrafts';
const DRAFTS_KEY = 'article-drafts';
/** Save a draft to localStorage. Returns the draft ID or null on failure. */
export function saveDraft(draft: Omit<Draft, 'id' | 'updatedAt'> & { id?: string }): string | null {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
const drafts: Draft[] = stored ? JSON.parse(stored) : [];
const existingIndex = draft.id
? drafts.findIndex(d => d.id === draft.id)
: drafts.findIndex(d => d.slug === draft.slug);
const newDraft: Draft = {
...draft,
id: draft.id || (existingIndex >= 0 ? drafts[existingIndex].id : crypto.randomUUID()),
updatedAt: Date.now(),
};
if (existingIndex >= 0) {
drafts[existingIndex] = newDraft;
} else {
drafts.unshift(newDraft);
}
// Keep only the 20 most recent drafts
const trimmedDrafts = drafts.slice(0, 20);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(trimmedDrafts));
return newDraft.id;
} catch (error) {
console.error('Failed to save draft:', error);
return null;
}
}
/** Delete a draft by slug from localStorage. */
export function deleteDraftBySlug(slug: string): void {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return;
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.slug !== slug);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to delete draft:', error);
}
}
/** Delete a draft by id from localStorage. Returns the remaining drafts. */
export function deleteLocalDraftById(id: string): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return [];
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.id !== id);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
return filtered;
} catch (error) {
console.error('Failed to delete draft:', error);
return [];
}
}
/** Get all local drafts. */
export function getLocalDrafts(): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
+4 -11
View File
@@ -8,11 +8,6 @@ import type { CoreThemeColors, ThemeConfig, ThemesConfig } from '@/themes';
/** Zod schema for Theme validation */
export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfies z.ZodType<Theme>;
/**
* Accepts current theme values as well as legacy values ("black", "pink")
* from older configs. Consumers should migrate legacy values to "custom".
*/
export const ThemeSchemaCompat = z.enum(['dark', 'light', 'system', 'custom', 'black', 'pink']);
/** HSL value string like "258 70% 55%" */
const HslValue = z.string().regex(/^\d/);
@@ -208,10 +203,8 @@ export const SavedFeedSchema = z.object({
/**
* Zod schema for the full AppConfig stored in localStorage.
*
* Uses compat sub-schemas (ThemeSchemaCompat, ThemeConfigCompatSchema) so
* legacy values parse successfully. Migration from legacy theme values
* ("black", "pink") to "custom" + customTheme is handled downstream by
* the AppProvider deserializer.
* Uses ThemeConfigCompatSchema for the customTheme field so legacy
* 19-token color objects still parse successfully.
*/
export const AppConfigSchema = z.object({
appName: z.string().optional(),
@@ -220,7 +213,7 @@ export const AppConfigSchema = z.object({
clientName: z.string().optional(),
client: z.string().optional(),
magicMouse: z.boolean().optional(),
theme: ThemeSchemaCompat,
theme: ThemeSchema,
customTheme: ThemeConfigCompatSchema.optional(),
autoShareTheme: z.boolean(),
themes: ThemesConfigSchema.optional(),
@@ -298,7 +291,7 @@ export const ContentFilterSchema = z.object({
* Uses looseObject to preserve unknown keys from newer app versions.
*/
export const EncryptedSettingsSchema = z.looseObject({
theme: ThemeSchemaCompat.optional(),
theme: ThemeSchema.optional(),
customTheme: ThemeConfigCompatSchema.optional(),
autoShareTheme: z.boolean().optional(),
useAppRelays: z.boolean().optional(),
+2
View File
@@ -26,6 +26,7 @@ import {
Egg,
Repeat2,
Scroll,
ScrollText,
Search,
Settings,
Smile,
@@ -133,6 +134,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
requiresAuth: true,
},
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
{
id: "letters",
label: "Letters",
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import { useSearchParams, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { nip19 } from 'nostr-tools';
import type { AddressPointer } from 'nostr-tools/nip19';
import { Loader2 } from 'lucide-react';
import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getLocalDrafts } from '@/lib/localDrafts';
import { parseArticleEvent } from '@/lib/articleHelpers';
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
export function ArticleEditorPage() {
useLayoutOptions({ showFAB: false, hasSubHeader: true });
const [searchParams] = useSearchParams();
const { naddr: naddrParam } = useParams<{ naddr: string }>();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const draftSlug = searchParams.get('draft');
const [initialData, setInitialData] = useState<(Partial<ArticleData> & { publishedAt?: number }) | undefined>(undefined);
const [editMode, setEditMode] = useState(false);
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
// Load draft from relay (NIP-37 kind 31234, encrypted) or localStorage if ?draft=<slug>
useEffect(() => {
if (!draftSlug) return;
const loadDraft = async () => {
if (user?.signer.nip44) {
try {
const events = await nostr.query([
{ kinds: [31234], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
]);
if (events.length > 0 && events[0].content.trim()) {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
setInitialData({
title: getTag('title'),
summary: getTag('summary'),
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
});
setLoading(false);
return;
}
} catch {
// Fall through to localStorage
}
}
// Fallback to localStorage
const drafts = getLocalDrafts();
const draft = drafts.find((d) => d.slug === draftSlug);
if (draft) {
setInitialData({
title: draft.title,
summary: draft.summary,
content: draft.content,
image: draft.image,
tags: draft.tags,
slug: draft.slug,
});
}
setLoading(false);
};
loadDraft();
}, [draftSlug, user, nostr]);
// Load existing article for editing if /articles/edit/:naddr
useEffect(() => {
if (!naddrParam) return;
let decoded: { type: string; data: AddressPointer };
try {
decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
if (decoded.type !== 'naddr') {
setLoading(false);
return;
}
} catch {
setLoading(false);
return;
}
const addr = decoded.data;
// Only allow editing your own articles
if (user && addr.pubkey !== user.pubkey) {
setLoading(false);
return;
}
nostr
.query([
{
kinds: [addr.kind],
authors: [addr.pubkey],
'#d': [addr.identifier],
limit: 1,
},
])
.then((events) => {
if (events.length > 0) {
setInitialData(parseArticleEvent(events[0]));
setEditMode(true);
}
})
.catch((err) => {
console.error('Failed to load article for editing:', err);
})
.finally(() => {
setLoading(false);
});
}, [naddrParam, nostr, user]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return <ArticleEditor initialData={initialData} editMode={editMode} />;
}
+410 -116
View File
@@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { Egg, Moon, Sun, Loader2, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink } from 'lucide-react';
import { Egg, Moon, Sun, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink, Settings2 } from 'lucide-react';
// Note: Sparkles kept for BlobbiBottomBar center action button
// Note: Plus kept for AdoptAnotherBlobbiCard
// Note: AlertTriangle kept for stat warning indicators
@@ -80,6 +80,7 @@ import {
type SelectedTrack,
type BlobbiReactionState,
type StartIncubationMode,
useDailyMissions,
} from '@/blobbi/actions';
import { BlobbiOnboardingFlow } from '@/blobbi/onboarding';
import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction';
@@ -88,6 +89,23 @@ import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { MissionSurfaceCard } from '@/blobbi/ui/components/MissionSurfaceCard';
import { ActionBarEditor } from '@/blobbi/ui/components/ActionBarEditor';
import {
type ActionBarPreferences,
type BarItemId,
getVisibleSlots,
getVisibleBarIds,
loadPreferences,
savePreferences,
loadMissionCardVisible,
saveMissionCardVisible,
} from '@/blobbi/ui/lib/action-bar-preferences';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour';
import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions';
import type { EggTourVisualState } from '@/blobbi/egg';
/**
* Get the localStorage key for the selected Blobbi.
@@ -871,6 +889,24 @@ function BlobbiDashboard({
const [showMissionsModal, setShowMissionsModal] = useState(false);
const [showShopModal, setShowShopModal] = useState(false);
const [showPhotoModal, setShowPhotoModal] = useState(false);
const [showBarEditor, setShowBarEditor] = useState(false);
// ─── Action Bar Preferences ───
const [barPrefs, setBarPrefs] = useState<ActionBarPreferences>(loadPreferences);
const handleBarPrefsUpdate = useCallback((prefs: ActionBarPreferences) => {
setBarPrefs(prefs);
savePreferences(prefs);
}, []);
// ─── Mission Surface Card Visibility ───
const [missionCardVisible, setMissionCardVisible] = useState(loadMissionCardVisible);
const handleToggleMissionCard = useCallback((visible: boolean) => {
setMissionCardVisible(visible);
saveMissionCardVisible(visible);
}, []);
// ─── Daily Missions (for surface card) ───
const dailyMissions = useDailyMissions({ availableStages });
// DEV ONLY: Emotion panel state
const [showEmotionPanel, setShowEmotionPanel] = useState(false);
@@ -934,6 +970,168 @@ function BlobbiDashboard({
const [showIncubationDialog, setShowIncubationDialog] = useState(false);
const [showEvolutionDialog, setShowEvolutionDialog] = useState(false);
// ─── First Hatch Tour ───
const firstHatchTour = useFirstHatchTour();
useFirstHatchTourActivation({
companions,
isLoading: false, // companions are already loaded at this point
tour: firstHatchTour,
profileOnboardingDone: profile?.onboardingDone,
});
const isFirstHatchTourActive = firstHatchTour.state.isActive;
// The required phrase for the first-hatch post
const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]);
// Auto-advance from idle -> show_hatch_card (immediately)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('idle')) {
firstHatchTour.actions.advance(); // -> show_hatch_card
}
}, [isFirstHatchTourActive, firstHatchTour]);
// Show the inline first-hatch card for all pre-hatch steps
const showFirstHatchCard = isFirstHatchTourActive && firstHatchTour.isAnyStep(
'show_hatch_card', 'egg_glowing_waiting_click',
'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3',
);
// Detect hatch post completion for the first-hatch tour
const { user } = useCurrentUser();
const { nostr } = useNostr();
const tourAwaitingPost = isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_card');
const { data: tourPostFound } = useQuery({
queryKey: ['first-hatch-tour-post', user?.pubkey, companion.name],
queryFn: async () => {
if (!user?.pubkey) return false;
const events = await nostr.query([{
kinds: [1],
authors: [user.pubkey],
limit: 20,
}]);
return events.some(e => isValidHatchPost(e, companion.name));
},
enabled: tourAwaitingPost && !!user?.pubkey,
refetchInterval: 5000,
staleTime: 3000,
});
// When the post is found during show_hatch_card, show the completed state
// for 2 seconds so the user sees the checkmark, then auto-advance to glowing.
useEffect(() => {
if (!tourPostFound || !isFirstHatchTourActive) return;
if (firstHatchTour.isStep('show_hatch_card')) {
const timer = setTimeout(() => {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}, 2000);
return () => clearTimeout(timer);
}
}, [tourPostFound, isFirstHatchTourActive, firstHatchTour]);
// Fake pointer hint: after 10s on glowing_waiting_click, show hint; repeat every 5s
const [showClickHint, setShowClickHint] = useState(false);
useEffect(() => {
if (!isFirstHatchTourActive || !firstHatchTour.isStep('egg_glowing_waiting_click')) {
setShowClickHint(false);
return;
}
const initial = setTimeout(() => setShowClickHint(true), 10000);
const repeat = setInterval(() => setShowClickHint(true), 5000);
return () => { clearTimeout(initial); clearInterval(repeat); };
}, [isFirstHatchTourActive, firstHatchTour]);
// Handle egg click during the tour (advance crack stages)
const handleTourEggClick = useCallback(() => {
if (!isFirstHatchTourActive) return;
setShowClickHint(false);
if (firstHatchTour.isStep('egg_glowing_waiting_click')) {
firstHatchTour.actions.advance(); // -> egg_crack_stage_1
} else if (firstHatchTour.isStep('egg_crack_stage_1')) {
firstHatchTour.actions.advance(); // -> egg_crack_stage_2
} else if (firstHatchTour.isStep('egg_crack_stage_2')) {
firstHatchTour.actions.advance(); // -> egg_crack_stage_3
} else if (firstHatchTour.isStep('egg_crack_stage_3')) {
firstHatchTour.actions.advance(); // -> egg_opening
}
}, [isFirstHatchTourActive, firstHatchTour]);
// Auto-advance for opening -> hatching -> complete (with hatch mutation)
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('egg_opening')) {
const timer = setTimeout(() => {
firstHatchTour.actions.advance(); // -> egg_hatching
}, 1500);
return () => clearTimeout(timer);
}
}, [isFirstHatchTourActive, firstHatchTour]);
useEffect(() => {
if (!isFirstHatchTourActive) return;
if (firstHatchTour.isStep('egg_hatching')) {
// Execute the actual hatch mutation, mark onboarding complete on the
// profile event, then complete the tour's local state.
const doHatch = async () => {
try {
await onHatch();
// Persist blobbi_onboarding_done to the Blobbonaut profile (authoritative)
if (profile) {
try {
const updatedTags = updateBlobbonautTags(profile.allTags, {
blobbi_onboarding_done: 'true',
});
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(event);
} catch (e) {
console.error('[FirstHatchTour] Failed to persist onboarding completion to profile:', e);
}
}
} finally {
firstHatchTour.actions.complete();
}
};
const timer = setTimeout(doHatch, 1200);
return () => clearTimeout(timer);
}
}, [isFirstHatchTourActive, firstHatchTour, onHatch, profile, publishEvent, updateProfileEvent]);
// Derive tourVisualState for the egg visual
const tourVisualState = useMemo((): EggTourVisualState => {
if (!isFirstHatchTourActive) return 'idle';
const step = firstHatchTour.state.currentStepId;
switch (step) {
case 'show_hatch_card': return 'show_hatch_card';
case 'egg_glowing_waiting_click': return 'glowing_waiting_click';
case 'egg_crack_stage_1': return 'crack_stage_1';
case 'egg_crack_stage_2': return 'crack_stage_2';
case 'egg_crack_stage_3': return 'crack_stage_3';
case 'egg_opening': return 'opening';
case 'egg_hatching': return 'hatching';
default: return 'idle';
}
}, [isFirstHatchTourActive, firstHatchTour.state.currentStepId]);
// DEV ONLY: Build tour dev actions for the state editor
const tourDevActions = useMemo(() => ({
skipPostRequirement: () => {
if (firstHatchTour.isStep('show_hatch_card')) {
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
}
},
resetTour: () => {
firstHatchTour.actions.reset();
},
currentStepId: firstHatchTour.state.currentStepId,
isCompleted: firstHatchTour.state.isCompleted,
}), [firstHatchTour]);
// State detection for tasks
// Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state
const isIncubating = companion.state === 'incubating';
@@ -1348,8 +1546,6 @@ function BlobbiDashboard({
{/* Hero Section */}
<div className="flex-1 flex flex-col items-center justify-center px-4 py-4 sm:px-6">
{/* Floating Dashboard Controls */}
<BlobbiDashboardFloatingControls />
{/* Blobbi Name */}
<div className="flex items-center gap-2 mb-6">
@@ -1363,7 +1559,6 @@ function BlobbiDashboard({
{/* Main Blobbi Visual */}
{isActiveFloatingCompanion ? (
// Show message when Blobbi is active as floating companion
<div className="flex flex-col items-center justify-center size-48 sm:size-56 text-center">
<Footprints className="size-12 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground text-sm">
@@ -1372,7 +1567,6 @@ function BlobbiDashboard({
</div>
) : (
<div className="relative transition-all duration-500">
{/* Subtle glow effect behind the egg */}
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
@@ -1383,15 +1577,34 @@ function BlobbiDashboard({
recipe={hasDevOverride ? undefined : statusRecipe}
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
tourVisualState={tourVisualState}
onTourEggClick={handleTourEggClick}
className="size-48 sm:size-56"
/>
{showClickHint && firstHatchTour.isStep('egg_glowing_waiting_click') && (
<div className="absolute bottom-14 inset-x-0 flex items-center justify-center pointer-events-none select-none">
<span className="text-4xl animate-bounce drop-shadow-lg">👆</span>
</div>
)}
</div>
)}
</div>
{/* Stats Section */}
{/* First Hatch Tour: inline card directly below egg (above stats) */}
{showFirstHatchCard && (
<div className="px-4 sm:px-6 mt-2">
<FirstHatchTourCard
blobbiName={companion.name}
requiredPhrase={firstHatchPhrase}
postCompleted={!!tourPostFound || !firstHatchTour.isStep('show_hatch_card')}
onCreatePost={() => setShowPostModal(true)}
currentStep={firstHatchTour.state.currentStepId}
/>
</div>
)}
{/* Stats Section - hidden during first-hatch tour */}
{!isFirstHatchTourActive && (
<div className="px-4 sm:px-6">
{/* Stats Grid - shows projected decay state */}
{/* Only stats below the visibility threshold are shown (centralized in getVisibleStatsWithValues) */}
@@ -1421,9 +1634,7 @@ function BlobbiDashboard({
);
})()}
{/* Inline Activity Area - inside padded container for proper spacing above bottom bar */}
{/* Inline Activity Area */}
{inlineActivity.type === 'music' && (
<div className="mt-6">
<InlineMusicPlayer
@@ -1450,39 +1661,66 @@ function BlobbiDashboard({
</div>
)}
</div>
)}
{/* Bottom Action Bar */}
<BlobbiBottomBar
onBlobbiesClick={() => setShowSelector(true)}
onMissionsClick={() => setShowMissionsModal(true)}
onActionsClick={() => setShowActionsModal(true)}
onShopClick={() => setShowShopModal(true)}
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
isInTaskProcess={isInTaskProcess}
remainingTasksCount={remainingTasksCount}
allTasksComplete={allTasksComplete}
stage={companion.stage}
blobbiNaddr={blobbiNaddr}
onSetAsCompanion={handleSetAsCompanion}
isCurrentCompanion={isCurrentCompanion}
isUpdatingCompanion={isUpdatingCompanion}
onTakePhoto={() => setShowPhotoModal(true)}
onEvolve={
canStartIncubation
? () => setShowIncubationDialog(true)
: canStartEvolution
? () => setShowEvolutionDialog(true)
: isEgg
? onHatch
: onEvolve
}
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
hideEvolveButton={isIncubating || isEvolvingState}
isIncubationAction={canStartIncubation}
isEvolutionAction={canStartEvolution}
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
onDevOpenEditor={() => setShowDevEditor(true)}
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
{/* Mission Surface Card - hidden during first-hatch tour or when user dismissed */}
{!isFirstHatchTourActive && missionCardVisible && (
<div className="px-4 sm:px-6 mt-3">
<MissionSurfaceCard
tasks={taskProcess.tasks}
isInTaskProcess={taskProcess.config.isActive}
processType={taskProcess.config.type}
dailyMissions={dailyMissions.missions}
onViewAll={() => setShowMissionsModal(true)}
onHide={() => handleToggleMissionCard(false)}
/>
</div>
)}
{/* Bottom Action Bar - hidden during first-hatch tour */}
{!isFirstHatchTourActive && (
<BlobbiBottomBar
onBlobbiesClick={() => setShowSelector(true)}
onMissionsClick={() => setShowMissionsModal(true)}
onActionsClick={() => setShowActionsModal(true)}
onShopClick={() => setShowShopModal(true)}
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
isInTaskProcess={isInTaskProcess}
remainingTasksCount={remainingTasksCount}
allTasksComplete={allTasksComplete}
stage={companion.stage}
blobbiNaddr={blobbiNaddr}
onSetAsCompanion={handleSetAsCompanion}
isCurrentCompanion={isCurrentCompanion}
isUpdatingCompanion={isUpdatingCompanion}
onTakePhoto={() => setShowPhotoModal(true)}
onEvolve={
canStartIncubation
? () => setShowIncubationDialog(true)
: canStartEvolution
? () => setShowEvolutionDialog(true)
: isEgg
? onHatch
: onEvolve
}
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
hideEvolveButton={isIncubating || isEvolvingState || isFirstHatchTourActive}
isIncubationAction={canStartIncubation}
isEvolutionAction={canStartEvolution}
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
onDevOpenEditor={() => setShowDevEditor(true)}
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
barPreferences={barPrefs}
onEditBar={() => setShowBarEditor(true)}
/>
)}
{/* Action Bar Editor */}
<ActionBarEditor
open={showBarEditor}
onOpenChange={setShowBarEditor}
preferences={barPrefs}
onUpdate={handleBarPrefsUpdate}
/>
{/* Blobbi Selector Modal */}
@@ -1596,6 +1834,8 @@ function BlobbiDashboard({
onStopEvolution={handleStopEvolution}
isStoppingEvolution={isStoppingEvolution}
availableStages={availableStages}
showMissionCard={missionCardVisible}
onToggleMissionCard={handleToggleMissionCard}
/>
{/* Shop & Inventory Modal (unified) */}
@@ -1617,6 +1857,7 @@ function BlobbiDashboard({
onSuccess={refetchCurrentTasks}
/>
{/* Blobbi Photo Modal - polaroid-style photo capture */}
<BlobbiPhotoModal
open={showPhotoModal}
@@ -1651,6 +1892,7 @@ function BlobbiDashboard({
companion={companion}
onApply={onDevEditorApply}
isUpdating={isDevUpdating}
tourDevActions={tourDevActions}
/>
)}
@@ -1665,39 +1907,6 @@ function BlobbiDashboard({
);
}
// ─── Quick Action Button ──────────────────────────────────────────────────────
interface QuickActionButtonProps {
children: React.ReactNode;
tooltip: string;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
}
function QuickActionButton({ children, tooltip, onClick, disabled, loading }: QuickActionButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={onClick}
disabled={disabled}
className="size-10 rounded-full bg-background/80 backdrop-blur-sm border-border hover:bg-accent hover:border-border transition-all shadow-sm"
>
{loading ? <Loader2 className="size-4 animate-spin" /> : children}
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
);
}
// ─── Dashboard Floating Controls ──────────────────────────────────────────────
/**
* Get the appropriate tooltip for the evolve/hatch button based on stage and action state.
*/
@@ -1715,18 +1924,6 @@ function getEvolveTooltip(
return 'Evolve';
}
/** Floating back button for the Blobbi dashboard. */
function BlobbiDashboardFloatingControls({ onBack }: { onBack?: () => void }) {
if (!onBack) return null;
return (
<div className="absolute top-28 sm:top-32 left-4 sm:left-6 flex flex-col gap-2 z-20">
<QuickActionButton tooltip="Go Back" onClick={onBack}>
<ArrowLeft className="size-4" />
</QuickActionButton>
</div>
);
}
// ─── Stat Indicator ───────────────────────────────────────────────────────────
interface StatIndicatorProps {
@@ -2065,12 +2262,18 @@ interface BlobbiBottomBarProps {
hideEvolveButton?: boolean;
isIncubationAction?: boolean;
isEvolutionAction?: boolean;
// ── Action bar preferences ──
barPreferences: ActionBarPreferences;
onEditBar: () => void;
// ── Dev-only actions ──
onDevInstantTransition?: () => void;
onDevOpenEditor?: () => void;
onDevOpenEmotionPanel?: () => void;
}
/** Handler map keyed by BarItemId so the bar can generically call the right action */
type BarItemHandlers = Record<BarItemId, () => void>;
function BlobbiBottomBar({
onBlobbiesClick,
onMissionsClick,
@@ -2092,21 +2295,74 @@ function BlobbiBottomBar({
hideEvolveButton = false,
isIncubationAction = false,
isEvolutionAction = false,
// Bar preferences
barPreferences,
onEditBar,
// Dev-only props
onDevInstantTransition,
onDevOpenEditor,
onDevOpenEmotionPanel,
}: BlobbiBottomBarProps) {
// Determine what to show on missions badge:
// - If all tasks complete during active process: show "!"
// - If tasks remaining during active process: show count
// - Otherwise: no badge
// Works for BOTH incubating (hatch) and evolving processes
const missionsBadge = allTasksComplete ? '!' : (isInTaskProcess && remainingTasksCount && remainingTasksCount > 0 ? remainingTasksCount : undefined);
const canBeCompanion = stage !== 'egg';
const showEvolveButton = stage !== 'adult' && !hideEvolveButton;
// Handler map for customizable items
const handlers: BarItemHandlers = useMemo(() => ({
blobbies: onBlobbiesClick,
missions: onMissionsClick,
items: onShopClick,
take_photo: onTakePhoto,
set_companion: onSetAsCompanion,
}), [onBlobbiesClick, onMissionsClick, onShopClick, onTakePhoto, onSetAsCompanion]);
// Icon map for customizable items
const iconMap: Record<BarItemId, React.ReactNode> = useMemo(() => ({
blobbies: <Egg className="size-4" />,
missions: <Target className="size-4" />,
items: <Package className="size-4" />,
take_photo: <Camera className="size-4" />,
set_companion: <Footprints className={cn('size-4', isCurrentCompanion && 'text-green-500')} />,
}), [isCurrentCompanion]);
// Label map
const labelMap: Record<BarItemId, string> = {
blobbies: 'Blobbies',
missions: 'Missions',
items: 'Items',
take_photo: 'Photo',
set_companion: isCurrentCompanion ? 'Companion' : 'Companion',
};
// Badge map
const badgeMap: Record<BarItemId, { badge?: number | string; variant?: 'default' | 'warning' | 'success' }> = useMemo(() => ({
blobbies: {
badge: needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined,
variant: needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' as const : 'default' as const,
},
missions: {
badge: missionsBadge,
variant: allTasksComplete ? 'success' as const : 'default' as const,
},
items: {},
take_photo: {},
set_companion: {},
}), [needyBlobbiesCount, missionsBadge, allTasksComplete]);
// Visible custom slots from preferences
const visibleSlots = getVisibleSlots(barPreferences);
// Set of item IDs currently visible in the bar (used to skip duplicates in More)
const visibleBarIds = useMemo(() => getVisibleBarIds(barPreferences), [barPreferences]);
// Split into left group (before center) and right group (after center)
// Distribute: first half to left, rest to right
const halfIdx = Math.ceil(visibleSlots.length / 2);
const leftSlots = visibleSlots.slice(0, halfIdx);
const rightSlots = visibleSlots.slice(halfIdx);
return (
<div className="mt-6 pt-2">
<div className="bg-card/95 backdrop-blur-md border border-border rounded-2xl px-1.5 sm:px-3 py-2 shadow-lg overflow-hidden">
@@ -2114,23 +2370,20 @@ function BlobbiBottomBar({
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-0.5 sm:gap-2">
{/* Left Group - aligned to end (closer to center) */}
<div className="flex items-center justify-end gap-0 sm:gap-1 overflow-hidden">
<BottomBarButton
onClick={onBlobbiesClick}
icon={<Egg className="size-4" />}
label="Blobbies"
badge={needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined}
badgeVariant={needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' : 'default'}
/>
<BottomBarButton
onClick={onMissionsClick}
icon={<Target className="size-4" />}
label="Missions"
badge={missionsBadge}
badgeVariant={allTasksComplete ? 'success' : 'default'}
/>
{leftSlots.map((slot) => (
<BottomBarButton
key={slot.id}
onClick={handlers[slot.id]}
icon={iconMap[slot.id]}
label={labelMap[slot.id]}
badge={badgeMap[slot.id].badge}
badgeVariant={badgeMap[slot.id].variant}
highlighted={slot.highlighted}
/>
))}
</div>
{/* Center Action Button */}
{/* Center Action Button (fixed) */}
<button
onClick={onActionsClick}
className="flex items-center justify-center size-11 sm:size-12 -mt-3 sm:-mt-4 mx-1 sm:mx-2 rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 active:scale-95 transition-all border-4 border-background shrink-0"
@@ -2140,9 +2393,19 @@ function BlobbiBottomBar({
{/* Right Group - aligned to start (closer to center) */}
<div className="flex items-center justify-start gap-0 sm:gap-1 overflow-hidden">
<BottomBarButton onClick={onShopClick} icon={<Package className="size-4" />} label="Items" />
{rightSlots.map((slot) => (
<BottomBarButton
key={slot.id}
onClick={handlers[slot.id]}
icon={iconMap[slot.id]}
label={labelMap[slot.id]}
badge={badgeMap[slot.id].badge}
badgeVariant={badgeMap[slot.id].variant}
highlighted={slot.highlighted}
/>
))}
{/* 3-dots menu */}
{/* More dropdown (fixed) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -2153,11 +2416,32 @@ function BlobbiBottomBar({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem onClick={onTakePhoto}>
<Camera className="size-4 mr-2" />
Take a Photo
</DropdownMenuItem>
{canBeCompanion && (
{/* Show items in More only when they're NOT already visible in the bar */}
{!visibleBarIds.has('blobbies') && (
<DropdownMenuItem onClick={onBlobbiesClick}>
<Egg className="size-4 mr-2" />
Blobbies
</DropdownMenuItem>
)}
{!visibleBarIds.has('items') && (
<DropdownMenuItem onClick={onShopClick}>
<Package className="size-4 mr-2" />
Items
</DropdownMenuItem>
)}
{!visibleBarIds.has('missions') && (
<DropdownMenuItem onClick={onMissionsClick}>
<Target className="size-4 mr-2" />
Missions
</DropdownMenuItem>
)}
{!visibleBarIds.has('take_photo') && (
<DropdownMenuItem onClick={onTakePhoto}>
<Camera className="size-4 mr-2" />
Take a Photo
</DropdownMenuItem>
)}
{canBeCompanion && !visibleBarIds.has('set_companion') && (
<DropdownMenuItem onClick={onSetAsCompanion} disabled={isUpdatingCompanion}>
<Footprints className={cn('size-4 mr-2', isCurrentCompanion && 'text-green-500')} />
{isCurrentCompanion ? 'Current Companion' : 'Set as Companion'}
@@ -2175,6 +2459,11 @@ function BlobbiBottomBar({
View Blobbi
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onEditBar}>
<Settings2 className="size-4 mr-2" />
Edit action bar
</DropdownMenuItem>
{/* DEV ONLY: Developer tools */}
{isLocalhostDev() && (onDevInstantTransition || onDevOpenEditor || onDevOpenEmotionPanel) && (
<>
@@ -2218,9 +2507,11 @@ interface BottomBarButtonProps {
badge?: number | string;
/** Badge color variant */
badgeVariant?: 'default' | 'warning' | 'success';
/** Show subtle highlight ring around this button */
highlighted?: boolean;
}
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default' }: BottomBarButtonProps) {
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default', highlighted }: BottomBarButtonProps) {
// Determine if badge should show
const showBadge = badge !== undefined && (typeof badge === 'string' || badge > 0);
@@ -2234,7 +2525,10 @@ function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default'
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]"
className={cn(
"flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]",
highlighted && "ring-1 ring-primary/30 bg-accent/30",
)}
>
<div className="relative">
{icon}
+187 -78
View File
@@ -1,43 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Bug, CalendarDays, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert, Tag } from 'lucide-react';
import { Bug, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/PageHeader';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAppContext } from '@/hooks/useAppContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { parseChangelog } from '@/lib/changelog';
import type { ChangelogCategory } from '@/lib/changelog';
import type { ChangelogCategory, ChangelogEntry } from '@/lib/changelog';
const GITLAB_REPO = 'https://gitlab.com/soapbox-pub/ditto';
/** Per-category badge color + icon. */
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; className: string }> = {
Added: {
icon: Plus,
className: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
},
Changed: {
icon: RefreshCw,
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
},
Deprecated: {
icon: Package,
className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
Removed: {
icon: Minus,
className: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
},
Fixed: {
icon: Bug,
className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
},
Security: {
icon: ShieldAlert,
className: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
},
/** Per-category icon + color used as inline list bullets. */
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; colorClass: string }> = {
Added: { icon: Plus, colorClass: 'text-emerald-600 dark:text-emerald-400' },
Changed: { icon: RefreshCw, colorClass: 'text-blue-600 dark:text-blue-400' },
Deprecated: { icon: Package, colorClass: 'text-orange-600 dark:text-orange-400' },
Removed: { icon: Minus, colorClass: 'text-red-600 dark:text-red-400' },
Fixed: { icon: Bug, colorClass: 'text-amber-600 dark:text-amber-400' },
Security: { icon: ShieldAlert, colorClass: 'text-purple-600 dark:text-purple-400' },
};
/** Format "2026-03-26" as a readable date string. */
@@ -92,55 +74,21 @@ export function ChangelogPage() {
<>
{isPreRelease && latestVersion && <PreReleaseBanner latestVersion={latestVersion} />}
{entries.map((entry) => (
<div key={entry.version} className="rounded-2xl border border-border overflow-hidden">
{/* Version header */}
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
<Tag className="size-4 text-primary shrink-0" />
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-sm hover:underline"
>
v{entry.version}
</a>
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
<CalendarDays className="size-3.5" />
<span>{formatDate(entry.date)}</span>
</a>
<LatestRelease entry={entries[0]} />
{entries.length > 1 && (
<>
<div className="flex items-center gap-3 pt-4 pb-1">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Past releases</span>
<div className="h-px flex-1 bg-border" />
</div>
{/* Sections */}
<div className="divide-y divide-border">
{entry.sections.map((section) => {
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
const Icon = style.icon;
return (
<div key={section.category} className="px-4 py-3 space-y-2">
<Badge variant="secondary" className={`gap-1 text-[10px] px-1.5 py-0 ${style.className}`}>
<Icon className="size-3" />
{section.category}
</Badge>
<ul className="space-y-1">
{section.items.map((item, i) => (
<li key={i} className="text-sm text-foreground/90 pl-3 relative before:absolute before:left-0 before:top-[0.6em] before:size-1 before:rounded-full before:bg-muted-foreground/40">
{item}
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
))}
{entries.slice(1).map((entry) => (
<ChangelogEntryCard key={entry.version} entry={entry} />
))}
</>
)}
</>
)}
</div>
@@ -148,6 +96,167 @@ export function ChangelogPage() {
);
}
/** Hero treatment for the most recent release — no card, centered version + date. */
function LatestRelease({ entry }: { entry: ChangelogEntry }) {
const contentRef = useRef<HTMLUListElement>(null);
const [overflows, setOverflows] = useState(false);
const [expanded, setExpanded] = useState(false);
const measure = useCallback(() => {
const el = contentRef.current;
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
}, []);
useEffect(() => {
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [measure]);
return (
<div className="pt-2 pb-1 px-4">
{/* Big centered version + date */}
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="block text-center text-2xl font-bold tracking-tight hover:underline"
>
v{entry.version}
</a>
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
{formatDate(entry.date)}
</a>
{/* Items */}
<div className="relative mt-4">
<ul
ref={contentRef}
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
className="space-y-2.5"
>
{entry.sections.flatMap((section) => {
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
const Icon = style.icon;
return section.items.map((item, i) => (
<li key={`${section.category}-${i}`} className="flex gap-2 text-base text-foreground/90">
<Tooltip>
<TooltipTrigger asChild>
<Icon className={`size-4 shrink-0 mt-1 cursor-default ${style.colorClass}`} />
</TooltipTrigger>
<TooltipContent side="left">{section.category}</TooltipContent>
</Tooltip>
<span>{item}</span>
</li>
));
})}
</ul>
{!expanded && overflows && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
{overflows && (
<button
className="w-full text-sm text-primary hover:underline mt-1"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
);
}
const ENTRY_MAX_HEIGHT = 240; // px — entries taller than this get a "Read more" button
/** A single changelog release card with truncation for long entries. */
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
const contentRef = useRef<HTMLUListElement>(null);
const [overflows, setOverflows] = useState(false);
const [expanded, setExpanded] = useState(false);
const measure = useCallback(() => {
const el = contentRef.current;
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
}, []);
useEffect(() => {
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [measure]);
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Version header */}
<div className="flex items-center gap-3 px-4 py-3">
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-sm hover:underline"
>
v{entry.version}
</a>
<a
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
<span>{formatDate(entry.date)}</span>
</a>
</div>
{/* Items */}
<div className="relative">
<ul
ref={contentRef}
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
className="px-4 py-3 space-y-2.5"
>
{entry.sections.flatMap((section) => {
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
const Icon = style.icon;
return section.items.map((item, i) => (
<li key={`${section.category}-${i}`} className="flex gap-2 text-sm text-foreground/90">
<Tooltip>
<TooltipTrigger asChild>
<Icon className={`size-3.5 shrink-0 mt-[3px] cursor-default ${style.colorClass}`} />
</TooltipTrigger>
<TooltipContent side="left">{section.category}</TooltipContent>
</Tooltip>
<span>{item}</span>
</li>
));
})}
</ul>
{!expanded && overflows && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
{overflows && (
<button
className="w-full text-sm text-primary hover:underline py-2"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
);
}
/** Banner shown at the top of the changelog for untagged (pre-release) builds. */
function PreReleaseBanner({ latestVersion }: { latestVersion: string }) {
return (
@@ -186,7 +295,7 @@ function ChangelogSkeleton() {
<div className="space-y-4 pt-1">
{[1, 2].map((i) => (
<div key={i} className="rounded-2xl border border-border overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-4 w-16" />
<div className="ml-auto flex items-center gap-1.5">
+1 -1
View File
@@ -50,7 +50,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
description: `${title} on Nostr`,
});
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
const kinds = Array.isArray(kind) ? kind : [kind];
+3 -2
View File
@@ -78,6 +78,7 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
30030: 'emoji pack',
30054: 'podcast episode',
30055: 'podcast trailer',
3063: 'asset',
30063: 'release',
30311: 'stream',
30315: 'status',
@@ -469,7 +470,7 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
actorPubkey={item.event.pubkey}
icon={
<span className="text-base leading-none size-4 flex items-center justify-center">
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4" />
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4 object-contain" />
</span>
}
action={`reacted to your ${noun}`}
@@ -569,7 +570,7 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
actors={group.actors}
icon={
<span className="text-base leading-none size-4 flex items-center justify-center">
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4" />
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4 object-contain" />
</span>
}
action={`reacted to your ${noun}`}
+35 -4
View File
@@ -12,6 +12,8 @@ import {
MessageCircle,
MoreHorizontal,
Radio,
Package,
Rocket,
Share2,
Star,
Zap,
@@ -51,7 +53,7 @@ import { RepostIcon } from "@/components/icons/RepostIcon";
import { LiveStreamPage } from "@/components/LiveStreamPage";
import { MagicDeckContent } from "@/components/MagicDeckContent";
import { MusicDetailContent } from "@/components/MusicDetailContent";
import { NoteCard } from "@/components/NoteCard";
import { EventActionHeader, NoteCard } from "@/components/NoteCard";
import { NoteContent } from "@/components/NoteContent";
import { NsiteCard } from "@/components/NsiteCard";
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
@@ -83,6 +85,8 @@ import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer";
import { WebxdcEmbed } from "@/components/WebxdcEmbed";
import { ProfileCard } from "@/components/ProfileCard";
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
import { ZapstoreReleaseContent, ZapstoreReleaseSkeleton, ZapstoreAssetContent, ZapstoreAssetSkeleton } from "@/components/ZapstoreReleaseContent";
import { AppHandlerContent } from "@/components/AppHandlerContent";
import { useAppContext } from "@/hooks/useAppContext";
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
@@ -133,6 +137,9 @@ function shellTitleForKind(kind?: number): string {
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
if (kind === BOOK_REVIEW_KIND) return "Book Review";
if (kind === 32267) return "App Details";
if (kind === 30063) return "Release";
if (kind === 3063) return "Asset";
if (kind === 31990) return "App";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
if (kind === 20) return "Photo";
@@ -998,6 +1005,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const isCustomNip = event.kind === 30817;
const isNsite = event.kind === 15128 || event.kind === 35128;
const isZapstoreApp = event.kind === 32267;
const isZapstoreRelease = event.kind === 30063;
const isZapstoreAsset = event.kind === 3063;
const isAppHandler = event.kind === 31990;
const isEncryptedDM = event.kind === 4;
const isLetter = event.kind === 8211;
const isVanish = event.kind === VANISH_KIND;
@@ -1025,6 +1035,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
!isCommunity &&
!isDevKind &&
!isZapstoreApp &&
!isZapstoreRelease &&
!isZapstoreAsset &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
!isVanish &&
@@ -1438,11 +1451,11 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
<div className="flex items-center gap-3">
{/* Reaction emoji bubble — size-10 matches the threaded ancestor avatar column */}
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="text-xl leading-none"
className="h-6 w-6 object-contain"
/>
</div>
@@ -1933,6 +1946,14 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
{/* Main post — expanded Ditto-style view */}
{!isReaction && !isRepost && !isVanish && !isZap && !isProfile && (
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
{/* Kind action header for app handlers */}
{isAppHandler && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published an app" />
)}
{isNsite && (
<EventActionHeader pubkey={event.pubkey} icon={Rocket} action="deployed an" noun="nsite" nounRoute="/development" />
)}
{/* Author row */}
<div className="flex items-center gap-3">
{author.isLoading ? (
@@ -2044,6 +2065,16 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</div>
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} />
) : isZapstoreRelease ? (
<Suspense fallback={<ZapstoreReleaseSkeleton />}>
<ZapstoreReleaseContent event={event} />
</Suspense>
) : isZapstoreAsset ? (
<Suspense fallback={<ZapstoreAssetSkeleton />}>
<ZapstoreAssetContent event={event} />
</Suspense>
) : isAppHandler ? (
<AppHandlerContent event={event} />
) : isEncryptedDM ? (
<EncryptedMessageContent event={event} />
) : isLetter ? (
@@ -2148,7 +2179,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<RenderResolvedEmoji
key={i}
emoji={emoji}
className="h-4 w-4 leading-none"
className="h-4 w-4 object-contain leading-none"
/>
))}
</span>
+1 -1
View File
@@ -1793,7 +1793,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
}, []);
useLayoutOptions(pubkey ? {
rightSidebar: <ProfileRightSidebar fields={fields} mediaEvents={mediaEvents} mediaLoading={mediaPending} onMediaClick={handleSidebarMediaClick} />,
rightSidebar: <ProfileRightSidebar fields={fields} pubkey={pubkey} onMediaClick={handleSidebarMediaClick} />,
showFAB: !(activeTab === 'wall' && !profileFollowsMe),
onFabClick: activeTab === 'wall' ? openWallCompose : undefined,
hasSubHeader: true,
+2 -22
View File
@@ -24,7 +24,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useProfileMedia } from '@/hooks/useProfileMedia';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -423,26 +423,6 @@ export function ProfileSettings() {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Fetch media events for the sidebar preview (same query as profile page)
const {
data: mediaData,
isPending: mediaPending,
} = useProfileMedia(user?.pubkey);
const mediaEvents = useMemo(() => {
if (!mediaData?.pages) return [];
const seen = new Set<string>();
const events: import('@nostrify/nostrify').NostrEvent[] = [];
for (const page of mediaData.pages) {
for (const event of page.events) {
if (!seen.has(event.id)) {
seen.add(event.id);
events.push(event);
}
}
}
return events;
}, [mediaData?.pages]);
const [cropState, setCropState] = useState<CropState | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showMobilePreview, setShowMobilePreview] = useState(false);
@@ -676,7 +656,7 @@ export function ProfileSettings() {
// Inject live sidebar preview into the app's right sidebar slot
useLayoutOptions({
rightSidebar: <ProfileRightSidebar fields={previewFields} mediaEvents={mediaEvents} mediaLoading={mediaPending} />,
rightSidebar: <ProfileRightSidebar fields={previewFields} pubkey={user?.pubkey} />,
});
if (!user) return <Navigate to="/settings" replace />;
+2 -6
View File
@@ -15,10 +15,6 @@ interface ImportMetaEnv {
readonly COMMIT_SHA: string;
/** Git tag for the current commit (e.g., "v2.0.0"). Empty string if untagged (pre-release build). */
readonly COMMIT_TAG: string;
/** Build-time configuration injected from ditto.json as a JSON string. `"null"` when no config file was provided. */
readonly DITTO_CONFIG: string;
}
/**
* Build-time configuration injected by Vite from ditto.json.
* `null` when no config file was provided at build time.
*/
declare const __DITTO_CONFIG__: Partial<import('@/contexts/AppContext').AppConfig> | null;
+11 -1
View File
@@ -109,6 +109,14 @@ export default {
'highlight-fade': {
from: { backgroundColor: 'hsl(var(--primary) / 0.10)' },
to: { backgroundColor: 'transparent' }
},
'collapsible-down': {
from: { height: '0' },
to: { height: 'var(--radix-collapsible-content-height)' }
},
'collapsible-up': {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: '0' }
}
},
animation: {
@@ -116,7 +124,9 @@ export default {
'accordion-up': 'accordion-up 0.2s ease-out',
'pending-glow': 'pending-glow 2.5s ease-in-out infinite',
'badge-spotlight': 'badge-spotlight 8s linear infinite',
'highlight-fade': 'highlight-fade 1.5s ease-out forwards'
'highlight-fade': 'highlight-fade 1.5s ease-out forwards',
'collapsible-down': 'collapsible-down 0.2s ease-out',
'collapsible-up': 'collapsible-up 0.2s ease-out'
}
}
},
+1 -1
View File
@@ -136,7 +136,7 @@ export default defineConfig(() => {
...(publicDir ? [mergePublicDir(publicDir)] : []),
],
define: {
__DITTO_CONFIG__: JSON.stringify(dittoConfig ?? null),
'import.meta.env.DITTO_CONFIG': JSON.stringify(JSON.stringify(dittoConfig ?? null)),
'import.meta.env.VERSION': JSON.stringify(pkg.version),
'import.meta.env.BUILD_DATE': JSON.stringify(new Date().toISOString()),
'import.meta.env.COMMIT_SHA': JSON.stringify(getCommitSha()),