Compare commits

...

106 Commits

Author SHA1 Message Date
Chad Curtis 283b31813c release: v2.6.0 2026-04-05 08:31:35 -05:00
Chad Curtis 6e1197a067 Redesign LinkFooter as compact icon+label chips 2026-04-05 08:27:37 -05:00
Chad Curtis b7d1fbf860 Fix mobile sidebar bottom links clipping into safe area 2026-04-05 08:09:21 -05:00
Chad Curtis 8fde660075 Fix Blobbi page missing bg-background/85 overlay on custom themes
DashboardShell uses fixed positioning on mobile, placing it directly
over the body background image. Without the bg-background/85 class
that MainLayout's center column provides, the raw background image
showed through unthemed. Add the same 85% opacity background overlay
used consistently across the rest of the app.
2026-04-05 07:29:06 -05:00
Chad Curtis 50c7d67928 Fix blobbi state resets caused by stale cache reads and invalidation races
All blobbi mutations now follow the read-modify-write pattern: fetch fresh
state from relays before mutating, then optimistically update the cache.
This prevents two classes of bugs:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

See merge request soapbox-pub/ditto!152
2026-04-02 16:51:27 +00:00
Alex Gleason c8e844a19a release: v2.3.1 2026-04-02 10:25:17 -05:00
Chad Curtis 205a252cac Fix slug collision check blocking edits to existing articles
Replace isEditMode guard with originalSlug comparison so the collision
check is skipped when republishing an article with the same slug it was
loaded with, but still runs if the user changes the slug to one that
would overwrite a different article.
2026-04-02 07:48:14 -05:00
Chad Curtis ad604eae68 Improve dialog UX on mobile: rounded corners, button spacing, keyboard awareness
- Add rounded-xl to Dialog and AlertDialog (was sm:rounded-lg only)
- Add consistent gap-2 to footer buttons on mobile (was no gap)
- Use w-[calc(100%-2rem)] for mobile side margins
- Push dialogs to top of viewport only when keyboard is visible via
  .keyboard-visible class on <html>, toggled by useKeyboardVisible
- Mount useKeyboardVisible globally in MainLayout so the class is
  always available for CSS-only consumers
2026-04-02 05:10:07 -05:00
Chad Curtis 57064b4f40 Save draft on blur and show cloud sync indicator
- Trigger silent draft save when title or editor loses focus
- Add onBlur prop to MilkdownEditor, wired to both WYSIWYG and source textarea
- Mark saved immediately after local write instead of waiting for relay
- Show persistent cloud icon in status; pulses while relay sync is in flight
2026-04-02 05:10:07 -05:00
Chad Curtis bb7b8da581 Always save drafts locally so they appear immediately in My Articles
Previously, drafts were only saved to localStorage on relay failure.
If the relay accepted the event but hadn't indexed it yet for queries,
the draft would show 'Saved' but not appear under My Articles. Now
we always persist locally first for instant visibility, then sync to
the relay in the background.
2026-04-02 05:10:07 -05:00
Chad Curtis 5683f6ea1e Fix source mode toggle clearing editor content
initialValueRef was only set once on mount, so toggling back from
source mode reinitialized Milkdown with stale content. Keep
initialValueRef and lastExternalValue in sync with the current value
so remounts and the replaceAll guard work correctly.
2026-04-02 05:10:07 -05:00
Chad Curtis 61c606822a Fix crash when editing in markdown source mode
The replaceAll effect tried to access editorViewCtx while in source
mode where the ProseMirror view isn't mounted, causing a 'Context
editorView not found' error. Skip the sync when sourceMode is active
and add a try/catch for the initial render race.
2026-04-02 05:10:07 -05:00
Chad Curtis bc12331cd4 Keep tab bar in article editor but make it non-sticky on mobile write mode
Add ARC_OVERHANG_PX spacer div after the header to prevent arc
overlapping content, matching the pattern used across other pages.
2026-04-02 05:10:07 -05:00
Chad Curtis 2478bf1c66 Improve mobile article editor UX when virtual keyboard is open
- Hide tab bar in write mode on mobile, replace with slim back+title header
- Hide publish FAB when keyboard is visible (was floating over content)
- Collapse metadata (summary, slug, tags) behind a 'Details' toggle on mobile
- Hide header image and stats bar when keyboard is up to maximize writing area
- Add useKeyboardVisible hook using Visual Viewport API
2026-04-02 05:10:07 -05:00
filemon ade9eb4999 Merge branch 'main' into update-hatch-action 2026-04-02 06:17:41 -03:00
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
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
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
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
filemon 00a9ad20de Merge branch 'main' into update-hatch-action 2026-04-02 03:13:16 -03: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
110 changed files with 9057 additions and 7336 deletions
+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
+84
View File
@@ -1,5 +1,89 @@
# Changelog
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.3.0"
versionName "2.6.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.3.0;
MARKETING_VERSION = 2.6.0;
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.3.0;
MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+61 -36
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.2.11",
"version": "2.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.2.11",
"version": "2.5.2",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
@@ -58,8 +58,8 @@
"@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/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -2486,9 +2486,9 @@
}
},
"node_modules/@nostrify/nostrify": {
"version": "0.51.0",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.0.tgz",
"integrity": "sha512-GLka8FHu7o04kpz/NB69JppQy3rbwkadr8Au2fLmYbbB478kkGuthF+U5JS2qKaAI137n1p5BN1eFsCk2JyuXQ==",
"version": "0.51.1",
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.1.tgz",
"integrity": "sha512-oPJhUiO1TlV5sGYizqAP4GvLijib34Uwh48wxlFimR/2MoCuSmab4AppcztGPNwxQoTKkJbLJwsSpl42V+WIXA==",
"dependencies": {
"@nostrify/types": "0.36.9",
"@scure/base": "^2.0.0",
@@ -2512,9 +2512,9 @@
}
},
"node_modules/@nostrify/nostrify/node_modules/@types/node": {
"version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -2527,11 +2527,11 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.0.tgz",
"integrity": "sha512-noroI4R2BS3GzEk55NGoWZkrBKDFHtM43HW99dnYdP+ecxtjBY6nYplypouUUkalHfTfGK2lQetLg5DvM2k2+w==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
"dependencies": {
"@nostrify/nostrify": "0.51.0",
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
},
"peerDependencies": {
@@ -5699,6 +5699,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5712,6 +5713,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5725,6 +5727,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5738,6 +5741,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5751,6 +5755,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5764,6 +5769,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5777,6 +5783,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5790,6 +5797,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5803,6 +5811,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5816,6 +5825,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5829,6 +5839,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5842,6 +5853,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5855,6 +5867,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5868,6 +5881,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5881,6 +5895,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5894,6 +5909,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5907,6 +5923,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5920,6 +5937,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5933,6 +5951,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5946,6 +5965,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5959,6 +5979,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5972,6 +5993,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5985,6 +6007,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5998,6 +6021,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6011,6 +6035,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -6160,9 +6185,9 @@
}
},
"node_modules/@smithy/is-array-buffer": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz",
"integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
"integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -6172,13 +6197,13 @@
}
},
"node_modules/@smithy/util-base64": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz",
"integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz",
"integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.1",
"@smithy/util-utf8": "^4.2.1",
"@smithy/util-buffer-from": "^4.2.2",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -6186,12 +6211,12 @@
}
},
"node_modules/@smithy/util-buffer-from": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz",
"integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
"integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.1",
"@smithy/is-array-buffer": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -6199,9 +6224,9 @@
}
},
"node_modules/@smithy/util-hex-encoding": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz",
"integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz",
"integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -6211,12 +6236,12 @@
}
},
"node_modules/@smithy/util-utf8": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz",
"integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
"integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.1",
"@smithy/util-buffer-from": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
@@ -15460,9 +15485,9 @@
"license": "MIT"
},
"node_modules/websocket-ts": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.2.1.tgz",
"integrity": "sha512-YKPDfxlK5qOheLZ2bTIiktZO1bpfGdNCPJmTEaPW7G9UXI1GKjDdeacOrsULUS000OPNxDVOyAuKLuIWPqWM0Q==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.3.0.tgz",
"integrity": "sha512-DocKMdXx7i8TCBMU+XUKZeUaKwQ7O2NPlxUcgb0poG4RwDrIqBo19mRdW00a1Sm7MSijhIEsgv9UJ0kB/qNy+Q==",
"license": "MIT"
},
"node_modules/whatwg-encoding": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.3.0",
"version": "2.6.0",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -64,8 +64,8 @@
"@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/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
+84
View File
@@ -1,5 +1,89 @@
# Changelog
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
### Fixed
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
## [2.4.0] - 2026-04-02
### Added
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
- Mission surface card in the feed that surfaces your active quests at a glance
### Changed
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
- Blobbi onboarding state now syncs to your profile so it follows you across devices
### Fixed
- Notification dot no longer reappears after you've already marked notifications as read
- Dialogs no longer fly up when the mobile keyboard opens
## [2.3.1] - 2026-04-02
### Changed
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
### Fixed
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
- Editing an existing article no longer incorrectly warns about a duplicate slug
- Switching between rich text and markdown source mode no longer clears your content
- Fix crash when editing in markdown source mode
## [2.3.0] - 2026-04-02
### Added
+5 -4
View File
@@ -51,6 +51,7 @@ const hardcodedConfig: AppConfig = {
appName: "Ditto",
appId: "ditto",
homePage: "feed",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
magicMouse: false,
theme: "system",
autoShareTheme: true,
@@ -123,11 +124,11 @@ const hardcodedConfig: AppConfig = {
"feed",
"notifications",
"search",
"themes",
"letters",
"badges",
"blobbi",
"theme",
"badges",
"emojis",
"letters",
"themes",
"settings",
"help",
],
+4
View File
@@ -77,6 +77,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const pollsDef = getExtraKindDef("polls")!;
@@ -151,6 +152,9 @@ export function AppRouter() {
</Suspense>
</BlobbiActionsProvider>
<Routes>
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
@@ -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>
);
}
@@ -17,6 +17,7 @@
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -24,7 +25,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
@@ -34,8 +40,6 @@ export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
export interface CareActivityResult {
@@ -59,8 +63,8 @@ export interface CareActivityResult {
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiCareActivityParams) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -78,12 +82,24 @@ export function useBlobbiCareActivity({
throw new Error('No companion available');
}
// Fetch fresh companion from relays (read-modify-write pattern)
const freshEvents = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [companion.d],
}]);
const freshCompanion = freshEvents
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at)
.map(e => parseBlobbiEvent(e))
.find(Boolean) ?? companion;
const now = new Date();
// Calculate what the streak update should be
// Calculate what the streak update should be using fresh data
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
freshCompanion.careStreak,
freshCompanion.careStreakLastDay,
now
);
@@ -96,29 +112,29 @@ export function useBlobbiCareActivity({
};
}
// Get the tag updates
const streakUpdates = getStreakTagUpdates(companion, now);
// Get the tag updates using fresh data
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
newStreak: freshCompanion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
// Build updated tags from fresh data
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
content: freshCompanion.event.content,
tags: updatedTags,
});
// Update local cache
// Update local cache (optimistic — no invalidation needed)
updateCompanionEvent(event);
// Update session tracker
@@ -128,9 +144,9 @@ export function useBlobbiCareActivity({
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: companion.careStreak,
previousStreak: freshCompanion.careStreak,
newStreak: result.newStreak,
lastDay: companion.careStreakLastDay,
lastDay: freshCompanion.careStreakLastDay,
newDay: result.newLastDay,
});
}
@@ -141,11 +157,6 @@ export function useBlobbiCareActivity({
action: result.action,
};
},
onSuccess: (result) => {
if (result.wasUpdated) {
invalidateCompanion();
}
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
@@ -69,10 +69,6 @@ export interface UseBlobbiDirectActionParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration happened) */
invalidateProfile: () => void;
}
/**
@@ -92,8 +88,6 @@ export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -189,12 +183,6 @@ export function useBlobbiDirectAction({
updateCompanionEvent(blobbiEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
action,
happinessChange: happinessDelta,
@@ -66,10 +66,6 @@ export interface UseStartIncubationParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -112,8 +108,6 @@ export function useStartIncubation({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -269,12 +263,6 @@ export function useStartIncubation({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -329,10 +317,6 @@ export interface UseStopIncubationParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -363,8 +347,6 @@ export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -435,12 +417,6 @@ export function useStopIncubation({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -480,10 +456,6 @@ export interface UseStartEvolutionParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -511,8 +483,6 @@ export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -585,12 +555,6 @@ export function useStartEvolution({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -631,10 +595,6 @@ export interface UseStopEvolutionParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -665,8 +625,6 @@ export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -736,12 +694,6 @@ export function useStopEvolution({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -784,10 +736,6 @@ export interface UseSyncTaskCompletionsParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
/**
@@ -827,8 +775,6 @@ export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -923,11 +869,6 @@ export function useSyncTaskCompletions({
});
updateCompanionEvent(event);
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
@@ -69,10 +69,6 @@ export interface UseBlobbiStageTransitionParams {
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -113,8 +109,6 @@ export function useBlobbiHatch({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -220,12 +214,6 @@ export function useBlobbiHatch({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'egg',
@@ -268,8 +256,6 @@ export function useBlobbiEvolve({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -376,12 +362,6 @@ export function useBlobbiEvolve({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'baby',
@@ -80,10 +80,6 @@ export interface UseBlobbiUseInventoryItemParams {
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
// Import NostrEvent type
@@ -107,8 +103,6 @@ export function useBlobbiUseInventoryItem({
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -145,15 +139,6 @@ export function useBlobbiUseInventoryItem({
throw new Error('Item not found in catalog');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
@@ -407,27 +392,29 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
// instead of profile.storage/profile.allTags to avoid restoring
// stale/legacy values after migration
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
// Only decrement storage if the item actually exists in inventory.
// Items are free to use regardless of inventory state.
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
if (hasItemInStorage) {
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
updateProfileEvent(profileEvent);
}
// ─── Invalidate Queries ───
invalidateCompanion();
invalidateProfile();
// No query invalidation needed — the optimistic updates above keep the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
+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
+1 -3
View File
@@ -55,8 +55,6 @@ export {
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
@@ -70,7 +68,7 @@ export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_WALL_EDIT,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
@@ -17,12 +17,14 @@ import { useMemo, memo, type RefObject } from 'react';
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
@@ -248,7 +250,14 @@ export function BlobbiCompanionVisual({
)}
style={{ transformOrigin: 'center bottom' }}
>
{(companion.stage === 'baby' || companion.stage === 'adult') && (
{companion.stage === 'egg' ? (
<BlobbiStageVisual
companion={companion as unknown as BlobbiCompanion}
size="sm"
animated={false}
className="size-full"
/>
) : (
<MemoizedBlobbiVisual
stage={companion.stage}
blobbi={blobbi}
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
}
/**
* End dragging - let gravity take over.
* End dragging - hold position where dropped.
*/
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
return {
...motion,
isDragging: false,
// If already at or below ground, snap to ground
isGrounded: motion.position.y >= groundY,
// Always treat as grounded so companion holds position where dropped
isGrounded: true,
position: {
...motion.position,
y: motion.position.y >= groundY ? groundY : motion.position.y,
// Clamp to ground if below it
y: Math.min(motion.position.y, groundY),
},
};
}
@@ -104,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
// Track if first entry has completed (for position initialization)
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
// Track viewport size
// Track viewport size — listen to both window resize and visualViewport
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
useEffect(() => {
const handleResize = () => {
setViewport({
@@ -114,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
};
window.addEventListener('resize', handleResize, { passive: true });
return () => window.removeEventListener('resize', handleResize);
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
return () => {
window.removeEventListener('resize', handleResize);
window.visualViewport?.removeEventListener('resize', handleResize);
};
}, []);
// Calculate bounds and positions
@@ -80,9 +80,6 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
if (!blobbi) return null;
// Only baby and adult can be companions
if (blobbi.stage === 'egg') return null;
// Use projected stats if available, otherwise fall back to base stats
const stats = projectedState?.stats ?? blobbi.stats;
@@ -19,9 +19,8 @@
* idle -> rising -> inspecting -> entering -> complete
*
* Route change behavior:
* - Cancels current entry immediately
* - Waits 1 second
* - Restarts entry for the new page
* - Companion keeps its current position (no re-entry animation)
* - Only initial mount and companion changes trigger entry animations
*/
import { useState, useEffect, useRef, useCallback } from 'react';
@@ -310,20 +309,11 @@ export function useBlobbiEntryAnimation({
// Random entry type for new companion (fall or rise)
const entryType: EntryType = Math.random() < 0.5 ? 'fall' : 'rise';
startEntry(entryType);
} else if (routeChanged && companionId) {
// Route changed - determine direction for new route
const entryType = getEntryDirection(previousPath, pathname, sidebarOrder);
// Immediately hide Blobbi and cancel current entry
cancelEntry();
setIsHiddenForTransition(true);
// Wait 1 second, then start the new entry animation
routeChangeTimeoutRef.current = setTimeout(() => {
startEntry(entryType);
}, entryConfig.routeChangeRestartDelay);
} else if (routeChanged) {
// Route changed - companion keeps its position, no re-entry animation.
// Just update the ref so future changes compare against the new path.
}
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
/**
* Animation loop for FALL entry.
@@ -69,14 +69,13 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
/** Optimistically update the TanStack cache so the companion reacts immediately. */
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
const parsed = parseBlobbiEvent(event);
if (!parsed) {
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
return;
}
if (!parsed) return;
// Optimistically update ALL blobbi-collection queries for this user.
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
// partial matching to find all entries regardless of dList shape.
// No invalidation needed — we fetched fresh from relays before mutating,
// so the optimistic update is the correct state.
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
const matchingQueries = queryClient.getQueriesData<CollectionData>({
queryKey: ['blobbi-collection', pubkey],
@@ -90,9 +89,6 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
companions: Object.values(newCompanionsByD),
});
}
// Also invalidate for background refetch to ensure eventual consistency
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
}, [queryClient]);
const toggleSleep = useCallback(async () => {
+90 -18
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -8,12 +9,18 @@ import { toast } from '@/hooks/useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
BLOBBONAUT_PROFILE_KINDS,
getBlobbonautQueryDValues,
buildMigrationTags,
generatePetId10,
getCanonicalBlobbiD,
isValidBlobbiEvent,
isValidBlobbonautEvent,
isLegacyBlobbonautKind,
migratePetInHas,
updateBlobbonautTags,
parseBlobbiEvent,
parseBlobbonautEvent,
parseStorageTags,
type BlobbiCompanion,
type BlobbonautProfile,
@@ -52,10 +59,6 @@ export interface EnsureCanonicalOptions {
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;
}
/**
@@ -111,6 +114,7 @@ export interface EnsureCanonicalResult {
* ```
*/
export function useBlobbiMigration() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -134,8 +138,6 @@ export function useBlobbiMigration() {
updateProfileEvent,
updateCompanionEvent,
updateStoredSelectedD,
invalidateCompanion,
invalidateProfile,
} = options;
if (!user?.pubkey) {
@@ -190,7 +192,8 @@ export function useBlobbiMigration() {
tags: profileTags,
});
// Update query caches
// Update query caches (optimistic — no invalidation needed since we
// fetch fresh from relays before every mutation)
updateProfileEvent(profileEvent);
updateCompanionEvent(canonicalEvent);
@@ -200,10 +203,6 @@ export function useBlobbiMigration() {
updateStoredSelectedD(canonicalD);
}
// Invalidate queries to refetch fresh data
invalidateCompanion?.();
invalidateProfile?.();
toast({
title: 'Pet upgraded!',
description: `${companion.name} has been migrated to the new format.`,
@@ -237,29 +236,102 @@ export function useBlobbiMigration() {
}
}, [user?.pubkey, publishEvent]);
/**
* Fetch the freshest companion event directly from relays, bypassing cache.
* This is the read step of the read-modify-write pattern.
*/
const fetchFreshCompanion = useCallback(async (
pubkey: string,
dTag: string,
): Promise<BlobbiCompanion | null> => {
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#d': [dTag],
}]);
const validEvents = events
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at);
if (validEvents.length === 0) return null;
return parseBlobbiEvent(validEvents[0]) ?? null;
}, [nostr]);
/**
* Fetch the freshest profile event directly from relays, bypassing cache.
*/
const fetchFreshProfile = useCallback(async (
pubkey: string,
): Promise<BlobbonautProfile | null> => {
const dValues = getBlobbonautQueryDValues(pubkey);
const events = await nostr.query([{
kinds: [...BLOBBONAUT_PROFILE_KINDS],
authors: [pubkey],
'#d': dValues,
}]);
const validEvents = events.filter(isValidBlobbonautEvent);
if (validEvents.length === 0) return null;
// Prefer current kind over legacy
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
if (currentKindEvents.length > 0) {
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
}
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
if (legacyKindEvents.length > 0) {
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
return parseBlobbonautEvent(sorted[0]) ?? null;
}
return null;
}, [nostr]);
/**
* Ensure a Blobbi is in canonical format before performing an action.
*
* CRITICAL: This fetches fresh data from relays (read-modify-write pattern)
* instead of using potentially stale cache data. This prevents state resets
* caused by publishing over a newer event with stale cached data.
*
* 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
* 1. Fetch fresh companion + profile from relays
* 2. Check if Blobbi is legacy
* 3. If legacy: migrate Blobbi
* 4. Return the resolved canonical Blobbi with fresh data
*
* All interaction handlers should call this before publishing events.
*/
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
options: EnsureCanonicalOptions
): Promise<EnsureCanonicalResult | null> => {
const { companion, profile } = options;
if (!user?.pubkey) return null;
const { companion: cachedCompanion, profile: cachedProfile } = options;
// Fetch fresh data from relays (read step of read-modify-write)
const [freshCompanion, freshProfile] = await Promise.all([
fetchFreshCompanion(user.pubkey, cachedCompanion.d),
fetchFreshProfile(user.pubkey),
]);
// Use fresh data, falling back to cached only if relay fetch returned nothing
const companion = freshCompanion ?? cachedCompanion;
const profile = freshProfile ?? cachedProfile;
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
const migrationResult = await migrateLegacyBlobbi(options);
// Use fresh data in migration options
const migrationOptions = { ...options, companion, profile };
const migrationResult = await migrateLegacyBlobbi(migrationOptions);
if (!migrationResult) {
// Migration failed, cannot proceed with action
@@ -279,7 +351,7 @@ export function useBlobbiMigration() {
};
}
// Companion is already canonical, return profile as-is
// Companion is already canonical, return fresh data
return {
wasMigrated: false,
companion,
@@ -288,7 +360,7 @@ export function useBlobbiMigration() {
profileAllTags: profile.allTags,
profileStorage: profile.storage,
};
}, [migrateLegacyBlobbi]);
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
return {
/** Migrate a legacy Blobbi to canonical format */
+33 -28
View File
@@ -132,7 +132,10 @@ export function useBlobbisCollection(dList: string[] | undefined) {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Helper to invalidate and refetch after publishing
// Helper to invalidate and refetch after publishing.
// NOTE: In most mutation paths this is no longer needed — the read-modify-write
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
const invalidate = useCallback(() => {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
@@ -141,36 +144,38 @@ export function useBlobbisCollection(dList: string[] | undefined) {
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Update a single companion event in the query cache (optimistic update)
// Update a single companion event in the query cache (optimistic update).
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different d-tag lists).
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,
};
}
);
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
const matchingQueries = queryClient.getQueriesData<CollectionData>({
queryKey: ['blobbi-collection', user.pubkey],
});
for (const [queryKey, data] of matchingQueries) {
if (!data) continue;
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
queryClient.setQueryData<CollectionData>(queryKey, {
companionsByD: newCompanionsByD,
companions: Object.values(newCompanionsByD),
});
}
// If no existing queries matched (first load), set our own query key
if (matchingQueries.length === 0) {
queryClient.setQueryData<CollectionData>(
['blobbi-collection', user.pubkey, queryKeyDTags],
{
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
},
);
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
@@ -190,7 +195,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
isStale: query.isStale,
/** Query error if any */
error: query.error,
/** Invalidate and refetch the collection */
/** Invalidate and refetch the collection (use only when d-tag set changes, not after mutations) */
invalidate,
/** Optimistically update a single companion in the cache */
updateCompanionEvent,
+1 -1
View File
@@ -110,7 +110,7 @@ export function toEggGraphicVisualBlobbi(
companion: BlobbiCompanion,
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
): EggVisualBlobbi {
const { visualTraits, stage, allTags } = companion;
const { visualTraits, stage, allTags = [] } = companion;
return {
// Colors pass through directly (already CSS hex values)
+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 ────────────────────────────────────────────────────────────
+3 -1
View File
@@ -527,8 +527,10 @@ export function BlobbiDevEditor({
onCheckedChange={setBreedingReady}
/>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
+1 -11
View File
@@ -7,7 +7,7 @@
* IMPORTANT: This hook should only be used in development mode.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -24,8 +24,6 @@ interface UseBlobbiDevUpdateParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
interface DevUpdateResult {
@@ -50,11 +48,9 @@ function generateBlobbiContent(name: string, stage: BlobbiStage): string {
export function useBlobbiDevUpdate({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiDevUpdateParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updates: BlobbiDevUpdates): Promise<DevUpdateResult> => {
@@ -169,12 +165,6 @@ export function useBlobbiDevUpdate({
// ─── Update Caches ───
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate collection queries
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey]
});
return {
previousStage: companion.stage,
+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
+397 -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;
}
}
@@ -393,3 +438,354 @@
filter: grayscale(1) contrast(1.5) !important;
}
}
/* ==========================================
Onboarding Hatching Ceremony Animations
========================================== */
/* Soft breathing pulse for the egg before interaction */
@keyframes egg-onboard-breathe {
0%, 100% {
transform: scale(1);
filter: brightness(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.08));
}
50% {
transform: scale(1.015);
filter: brightness(1.03) drop-shadow(0 0 30px rgba(255, 255, 255, 0.15));
}
}
.animate-egg-onboard-breathe {
animation: egg-onboard-breathe 3s ease-in-out infinite;
}
/* Screen-filling radial glow that expands from center on hatch */
@keyframes onboard-glow-expand {
0% {
opacity: 0;
transform: scale(0.3);
}
30% {
opacity: 1;
}
100% {
opacity: 0.85;
transform: scale(2.5);
}
}
.animate-onboard-glow-expand {
animation: onboard-glow-expand 1.8s ease-out forwards;
}
/* Gentle lingering glow fade after hatch - holds then fades */
@keyframes onboard-glow-linger {
0% {
opacity: 0.85;
}
15% {
opacity: 0.85;
}
100% {
opacity: 0;
}
}
.animate-onboard-glow-linger {
animation: onboard-glow-linger 7s ease-out forwards;
}
/* Sentimental text fade in - very slow, dreamlike */
@keyframes onboard-text-reveal {
0% {
opacity: 0;
transform: translateY(12px);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.animate-onboard-text-reveal {
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Delayed text reveal for secondary text */
.animate-onboard-text-reveal-delay {
opacity: 0;
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards;
}
/* Soft fade out for transition between phases */
@keyframes onboard-soft-fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-onboard-soft-fade-out {
animation: onboard-soft-fade-out 0.8s ease-out forwards;
}
/* Soft fade in */
@keyframes onboard-soft-fade-in {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-onboard-soft-fade-in {
animation: onboard-soft-fade-in 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Floating particles that drift upward from the egg */
@keyframes onboard-particle-rise {
0% {
opacity: 0;
transform: translateY(0) scale(0.5);
}
20% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: translateY(-120px) scale(0.2);
}
}
/* Sparkle twinkle - stays in place, pulses brightness */
@keyframes onboard-sparkle-twinkle {
0%, 100% {
opacity: 0;
transform: scale(0.5);
}
15% {
opacity: 1;
transform: scale(1.2);
}
30% {
opacity: 0.6;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 0.3;
transform: scale(0.6);
}
85% {
opacity: 0.9;
transform: scale(1.1);
}
}
/* Sparkle drift - gentle floating motion */
@keyframes onboard-sparkle-drift {
0% {
opacity: 0;
transform: translateY(0) scale(0.3);
}
20% {
opacity: 1;
transform: translateY(-8px) scale(1);
}
80% {
opacity: 0.8;
transform: translateY(-25px) scale(0.9);
}
100% {
opacity: 0;
transform: translateY(-40px) scale(0.4);
}
}
/* Egg entrance - subtle float up from darkness */
@keyframes egg-onboard-entrance {
0% {
opacity: 0;
transform: translateY(30px) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-egg-onboard-entrance {
animation: egg-onboard-entrance 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Egg shake intensifying - for crack stages */
@keyframes egg-onboard-shake-light {
0%, 100% { transform: translateX(0) rotate(0deg); }
25% { transform: translateX(-3px) rotate(-2deg); }
75% { transform: translateX(3px) rotate(2deg); }
}
@keyframes egg-onboard-shake-medium {
0%, 100% { transform: translateX(0) rotate(0deg); }
20% { transform: translateX(-5px) rotate(-3deg); }
40% { transform: translateX(4px) rotate(2deg); }
60% { transform: translateX(-4px) rotate(-2deg); }
80% { transform: translateX(5px) rotate(3deg); }
}
@keyframes egg-onboard-shake-heavy {
0%, 100% { transform: translateX(0) rotate(0deg); }
10% { transform: translateX(-6px) rotate(-4deg); }
20% { transform: translateX(5px) rotate(3deg); }
30% { transform: translateX(-7px) rotate(-3deg); }
40% { transform: translateX(6px) rotate(4deg); }
50% { transform: translateX(-5px) rotate(-2deg); }
60% { transform: translateX(7px) rotate(3deg); }
70% { transform: translateX(-6px) rotate(-4deg); }
80% { transform: translateX(5px) rotate(2deg); }
90% { transform: translateX(-4px) rotate(-3deg); }
}
.animate-egg-onboard-shake-light {
animation: egg-onboard-shake-light 0.4s ease-in-out;
}
.animate-egg-onboard-shake-medium {
animation: egg-onboard-shake-medium 0.5s ease-in-out;
}
.animate-egg-onboard-shake-heavy {
animation: egg-onboard-shake-heavy 0.6s ease-in-out;
}
/* Final burst - egg explodes into light */
@keyframes egg-onboard-burst {
0% {
transform: scale(1);
opacity: 1;
filter: brightness(1);
}
30% {
transform: scale(1.08);
filter: brightness(1.5);
}
60% {
transform: scale(1.15);
opacity: 0.8;
filter: brightness(2.5);
}
100% {
transform: scale(1.3);
opacity: 0;
filter: brightness(4) blur(8px);
}
}
.animate-egg-onboard-burst {
animation: egg-onboard-burst 1.2s ease-in-out forwards;
}
/* Screen flash on hatch */
@keyframes onboard-screen-flash {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.animate-onboard-screen-flash {
animation: onboard-screen-flash 2s ease-out forwards;
}
/* Gentle continue prompt pulse */
@keyframes onboard-continue-pulse {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 0.7;
}
}
.animate-onboard-continue-pulse {
animation: onboard-continue-pulse 2.5s ease-in-out infinite;
}
/* Slow rotating golden incandescence behind hatched blobbi */
@keyframes onboard-golden-rotate {
0% {
transform: rotate(0deg) scale(1);
}
25% {
transform: rotate(90deg) scale(1.06);
}
50% {
transform: rotate(180deg) scale(1);
}
75% {
transform: rotate(270deg) scale(1.06);
}
100% {
transform: rotate(360deg) scale(1);
}
}
.animate-onboard-golden-rotate {
animation: onboard-golden-rotate 20s linear infinite;
}
/* Golden glow fade-in */
@keyframes onboard-golden-fadein {
0% {
opacity: 0;
transform: scale(0.7);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.animate-onboard-golden-fadein {
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* Reduced motion overrides for onboarding */
@media (prefers-reduced-motion: reduce) {
.animate-egg-onboard-breathe,
.animate-onboard-glow-expand,
.animate-onboard-glow-linger,
.animate-onboard-text-reveal,
.animate-onboard-text-reveal-delay,
.animate-onboard-soft-fade-out,
.animate-onboard-soft-fade-in,
.animate-egg-onboard-entrance,
.animate-egg-onboard-shake-light,
.animate-egg-onboard-shake-medium,
.animate-egg-onboard-shake-heavy,
.animate-egg-onboard-burst,
.animate-onboard-screen-flash,
.animate-onboard-continue-pulse,
.animate-onboard-golden-rotate,
.animate-onboard-golden-fadein {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}
@@ -0,0 +1,961 @@
/**
* BlobbiHatchingCeremony - Immersive hatching experience for every new egg
*
* Flow:
* 1. Dark screen, egg silently created in background
* 2. Huge breathing egg appears. No text. No UI.
* 3. Click egg 4 times through crack stages with intensifying shakes
* 4. Final click -> egg bursts into light, actual hatch mutation fires
* 5. Flash clears -> hatched baby blobbi revealed center screen with glow/sparkles
* 6. Typewriter dialog appears below blobbi (click to complete line / advance)
* 7. Naming prompt, then ceremony complete
*/
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
INITIAL_BLOBBONAUT_COINS,
STAT_MAX,
buildBlobbonautTags,
updateBlobbonautTags,
updateBlobbiTags,
type BlobbonautProfile,
type BlobbiCompanion,
} from '@/blobbi/core/lib/blobbi';
import {
generateEggPreview,
previewToEventTags,
previewToBlobbiCompanion,
type BlobbiEggPreview,
} from '../lib/blobbi-preview';
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
const BIRTH_DIALOG: string[] = [
'Something stirs...',
'A tiny life has chosen you. It knows only warmth, and your presence.',
];
const NAMING_DIALOG = 'Every life deserves a name.\nWhat will you call this one?';
// ─── Phase Machine ────────────────────────────────────────────────────────────
type CeremonyPhase =
| 'loading'
| 'egg'
| 'crack_1'
| 'crack_2'
| 'crack_3'
| 'hatching' // egg burst + hatch mutation
| 'reveal' // flash clearing, baby blobbi fading in with glow
| 'dialog' // typewriter dialog lines
| 'naming'
| 'complete';
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
function useTypewriter(fullText: string, active: boolean, speed = 35) {
const [displayed, setDisplayed] = useState('');
const [done, setDone] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const indexRef = useRef(0);
// Reset when text changes
useEffect(() => {
setDisplayed('');
setDone(false);
indexRef.current = 0;
}, [fullText]);
// Run typewriter
useEffect(() => {
if (!active || done) return;
intervalRef.current = setInterval(() => {
indexRef.current++;
const next = fullText.slice(0, indexRef.current);
setDisplayed(next);
if (indexRef.current >= fullText.length) {
setDone(true);
if (intervalRef.current) clearInterval(intervalRef.current);
}
}, speed);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [active, done, fullText, speed]);
const complete = useCallback(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
setDisplayed(fullText);
setDone(true);
}, [fullText]);
return { displayed, done, complete };
}
// Module-level guard: prevents duplicate egg creation if the component remounts
// (e.g. React strict mode, parent re-render causing unmount/remount).
// Tracks pubkeys that have already started setup in this browser session.
const setupInFlightFor = new Set<string>();
// ─── Props ────────────────────────────────────────────────────────────────────
interface BlobbiHatchingCeremonyProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
updateCompanionEvent: (event: NostrEvent) => void;
invalidateProfile: () => void;
invalidateCompanion: () => void;
setStoredSelectedD: (d: string) => void;
onComplete?: () => void;
/** If provided, skip egg creation and start from the cracking phase with this existing egg. */
existingCompanion?: BlobbiCompanion | null;
/** If true, only create the egg and skip the hatching ceremony. The egg stays an egg. */
eggOnly?: boolean;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiHatchingCeremony({
profile,
updateProfileEvent,
updateCompanionEvent,
invalidateProfile,
invalidateCompanion,
setStoredSelectedD,
onComplete,
existingCompanion,
eggOnly = false,
}: BlobbiHatchingCeremonyProps) {
const isExistingEgg = !!existingCompanion;
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { data: authorData } = useAuthor(user?.pubkey);
// ── Core state ──
const [phase, setPhase] = useState<CeremonyPhase>('loading');
const [preview, setPreview] = useState<BlobbiEggPreview | null>(null);
const [name, setName] = useState(existingCompanion?.name ?? '');
const [isNaming, setIsNaming] = useState(false);
const [eggVisible, setEggVisible] = useState(false);
// Reveal phase state
const [blobbiVisible, setBlobbiVisible] = useState(false);
const [showFlash, setShowFlash] = useState(false);
const [, setShowRevealGlow] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
// Dialog state
const [dialogLineIndex, setDialogLineIndex] = useState(0);
const [dialogActive, setDialogActive] = useState(false);
const [namingVisible, setNamingVisible] = useState(false);
// Refs
const setupAttempted = useRef(false);
const profileRef = useRef(profile);
profileRef.current = profile;
const previewRef = useRef(preview);
previewRef.current = preview;
const nameInputRef = useRef<HTMLInputElement>(null);
const eggContainerRef = useRef<HTMLDivElement>(null);
const entrancePlayed = useRef(false);
const eggTagsRef = useRef<string[][] | null>(null);
// ── Companion visuals ──
const eggCompanion = useMemo(
() => preview ? previewToBlobbiCompanion(preview) : null,
// eslint-disable-next-line react-hooks/exhaustive-deps
[preview?.d],
);
// Baby companion (same visual data but stage=baby)
const babyCompanion = useMemo((): BlobbiCompanion | null => {
if (!eggCompanion) return null;
return { ...eggCompanion, stage: 'baby', state: 'active' };
}, [eggCompanion]);
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
// ── Typewriter for current dialog line ──
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
const namingTypewriter = useTypewriter(NAMING_DIALOG, namingVisible);
// ── Fast-path setup for existing eggs (no publishing needed) ──
useEffect(() => {
if (!isExistingEgg || setupAttempted.current || !existingCompanion) return;
setupAttempted.current = true;
// Build a minimal preview from the existing companion
const fakePreview: BlobbiEggPreview = {
d: existingCompanion.d,
petId: existingCompanion.d,
ownerPubkey: user?.pubkey ?? '',
name: existingCompanion.name,
stage: 'egg',
state: 'active',
seed: existingCompanion.seed ?? '',
stats: {
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
happiness: existingCompanion.stats.happiness ?? STAT_MAX,
health: existingCompanion.stats.health ?? STAT_MAX,
hygiene: existingCompanion.stats.hygiene ?? STAT_MAX,
energy: existingCompanion.stats.energy ?? STAT_MAX,
},
visualTraits: existingCompanion.visualTraits,
createdAt: Math.floor(Date.now() / 1000),
};
setPreview(fakePreview);
previewRef.current = fakePreview;
eggTagsRef.current = existingCompanion.allTags;
setPhase('egg');
setTimeout(() => setEggVisible(true), 200);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isExistingEgg, existingCompanion?.d]);
// ── Silent setup: create profile + egg (new egg flow only) ──
useEffect(() => {
if (isExistingEgg) return; // Skip for existing eggs
if (setupAttempted.current || !user?.pubkey) return;
// Module-level guard: if another mount already started setup for this pubkey, skip
if (setupInFlightFor.has(user.pubkey)) return;
setupAttempted.current = true;
setupInFlightFor.add(user.pubkey);
const setup = async () => {
try {
const currentProfile = profileRef.current;
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
// 1. Create profile if needed
if (!currentProfile) {
const suggestedName =
authorData?.metadata?.display_name ||
authorData?.metadata?.name ||
'Blobbonaut';
const baseTags = buildBlobbonautTags(user.pubkey);
const tagsWithName = [
...baseTags,
['name', suggestedName],
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
];
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: tagsWithName,
});
updateProfileEvent(profileEvent);
invalidateProfile();
latestProfileTags = tagsWithName;
}
// 2. Generate and publish egg
const eggPreview = generateEggPreview(user.pubkey, 'Egg');
setPreview(eggPreview);
previewRef.current = eggPreview;
const eggTags = previewToEventTags(eggPreview);
eggTagsRef.current = eggTags;
const eggEvent = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: 'A new Blobbi egg!',
tags: eggTags,
created_at: eggPreview.createdAt,
});
updateCompanionEvent(eggEvent);
// 3. Update profile with has[] entry
if (latestProfileTags) {
const existingHas = latestProfileTags
.filter(([k]) => k === 'has')
.map(([, v]) => v);
const newHas = [...existingHas, eggPreview.d];
const updatedTags = updateBlobbonautTags(latestProfileTags, {
has: newHas,
});
const updatedProfileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(updatedProfileEvent);
}
setStoredSelectedD(eggPreview.d);
invalidateProfile();
invalidateCompanion();
setPhase('egg');
setTimeout(() => setEggVisible(true), 200);
} catch (error) {
console.error('[HatchingCeremony] Setup failed:', error);
toast({
title: 'Something went wrong',
description: 'Failed to set up your Blobbi. Please try again.',
variant: 'destructive',
});
} finally {
// Clear module-level guard so future adoptions can create new eggs
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
}
};
const timer = setTimeout(setup, 600);
return () => {
clearTimeout(timer);
// If the timer was cleared before setup ran, release the guard
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.pubkey]);
useEffect(() => {
if (profile) profileRef.current = profile;
}, [profile]);
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
useEffect(() => {
if (!eggOnly || !eggVisible) return;
const timer = setTimeout(() => {
setPhase('complete');
onComplete?.();
}, 1500);
return () => clearTimeout(timer);
}, [eggOnly, eggVisible, onComplete]);
// Play entrance animation once
useEffect(() => {
if (eggVisible && !entrancePlayed.current && eggContainerRef.current) {
entrancePlayed.current = true;
const el = eggContainerRef.current;
el.classList.add('animate-egg-onboard-entrance');
const onEnd = () => {
el.classList.remove('animate-egg-onboard-entrance');
el.removeEventListener('animationend', onEnd);
};
el.addEventListener('animationend', onEnd);
}
}, [eggVisible]);
// ── Shake (DOM-only, no re-render) ──
const triggerShake = useCallback((cls: string) => {
const el = eggContainerRef.current;
if (!el) return;
el.classList.remove(
'animate-egg-onboard-shake-light',
'animate-egg-onboard-shake-medium',
'animate-egg-onboard-shake-heavy',
);
void el.offsetWidth;
el.classList.add(cls);
}, []);
// ── Execute the actual hatch: egg -> baby ──
const executeHatch = useCallback(async () => {
const tags = eggTagsRef.current;
if (!tags) return;
const now = Math.floor(Date.now() / 1000);
const nowStr = now.toString();
const babyTags = updateBlobbiTags(tags, {
stage: 'baby',
state: 'active',
hunger: STAT_MAX.toString(),
happiness: STAT_MAX.toString(),
health: STAT_MAX.toString(),
hygiene: STAT_MAX.toString(),
energy: STAT_MAX.toString(),
last_interaction: nowStr,
last_decay_at: nowStr,
});
const babyName = previewRef.current?.name ?? 'Egg';
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: `${babyName} is a baby Blobbi.`,
tags: babyTags,
});
eggTagsRef.current = babyTags;
updateCompanionEvent(event);
invalidateCompanion();
}, [publishEvent, updateCompanionEvent, invalidateCompanion]);
// ── Egg click ──
const handleEggClick = useCallback(() => {
if (phase === 'egg') {
triggerShake('animate-egg-onboard-shake-light');
setPhase('crack_1');
} else if (phase === 'crack_1') {
triggerShake('animate-egg-onboard-shake-medium');
setPhase('crack_2');
} else if (phase === 'crack_2') {
triggerShake('animate-egg-onboard-shake-heavy');
setPhase('crack_3');
} else if (phase === 'crack_3') {
// Final click -> hatch!
setPhase('hatching');
setShowFlash(true);
// Fire the actual hatch mutation
executeHatch().catch(console.error);
// After flash, reveal the baby
setTimeout(() => {
setShowFlash(false);
setShowRevealGlow(true);
setPhase('reveal');
// Fade in blobbi
setTimeout(() => setBlobbiVisible(true), 400);
// After blobbi settles, start dialog
setTimeout(() => {
setPhase('dialog');
setDialogLineIndex(0);
setDialogActive(true);
}, 2200);
}, 1400);
}
}, [phase, triggerShake, executeHatch]);
// ── Dialog click: complete line or advance ──
const handleDialogClick = useCallback(() => {
if (phase !== 'dialog') return;
if (!dialogTypewriter.done) {
// Complete the current line instantly
dialogTypewriter.complete();
return;
}
// Advance to next line
const nextIndex = dialogLineIndex + 1;
if (nextIndex < BIRTH_DIALOG.length) {
setDialogActive(false);
setDialogLineIndex(nextIndex);
// Small pause before next line starts
setTimeout(() => setDialogActive(true), 150);
} else {
// All lines done -> naming
setDialogActive(false);
setTimeout(() => {
setPhase('naming');
setTimeout(() => {
setNamingVisible(true);
setTimeout(() => nameInputRef.current?.focus(), 600);
}, 200);
}, 400);
}
}, [phase, dialogTypewriter, dialogLineIndex]);
// ── Complete ceremony ──
const completeCeremony = useCallback(async (finalName: string) => {
try {
// Update egg/baby name if changed
const currentTags = eggTagsRef.current;
if (currentTags && finalName !== (previewRef.current?.name ?? 'Egg')) {
const namedTags = updateBlobbiTags(currentTags, { name: finalName });
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: `${finalName} is a baby Blobbi.`,
tags: namedTags,
});
updateCompanionEvent(event);
}
// Mark onboarding done
const currentProfile = profileRef.current;
if (currentProfile) {
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
blobbi_onboarding_done: 'true',
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
updateProfileEvent(profileEvent);
}
invalidateProfile();
invalidateCompanion();
} catch (error) {
console.error('[HatchingCeremony] Failed to persist completion:', error);
}
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
// ── Naming submit ──
const handleNameSubmit = useCallback(async () => {
if (isNaming || !name.trim()) return;
setIsNaming(true);
try {
await completeCeremony(name.trim());
setNamingVisible(false);
// Fade to white, then complete
setTimeout(() => {
setFadeOut(true);
setTimeout(() => {
setPhase('complete');
onComplete?.();
}, 2200);
}, 600);
} catch (error) {
console.error('[HatchingCeremony] Naming failed:', error);
toast({
title: 'Failed to save name',
description: 'Your Blobbi was created, but the name could not be saved.',
variant: 'destructive',
});
setFadeOut(true);
setTimeout(() => {
setPhase('complete');
onComplete?.();
}, 2200);
} finally {
setIsNaming(false);
}
}, [name, isNaming, completeCeremony, onComplete]);
// ── Tour visual state for EggGraphic crack rendering ──
const tourVisualState = useMemo(() => {
switch (phase) {
case 'crack_1': return 'crack_stage_1' as const;
case 'crack_2': return 'crack_stage_2' as const;
case 'crack_3': return 'crack_stage_3' as const;
case 'hatching': return 'opening' as const;
default: return 'idle' as const;
}
}, [phase]);
// ── Render ──
const isEggPhase = phase === 'egg' || phase === 'crack_1' || phase === 'crack_2' || phase === 'crack_3';
const isHatching = phase === 'hatching';
const showBaby = phase === 'reveal' || phase === 'dialog' || phase === 'naming';
if (phase === 'loading') {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)' }}
>
<div
className="absolute size-32 rounded-full opacity-20 animate-pulse"
style={{ background: `radial-gradient(circle, ${eggColor}40 0%, transparent 70%)` }}
/>
</div>
);
}
return (
<div
className="fixed inset-0 z-50 overflow-hidden select-none"
style={{
background: showBaby
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
transition: 'background 2s ease-out',
}}
onClick={phase === 'dialog' ? handleDialogClick : undefined}
>
{/* ── Ambient background glow (egg phase only) ── */}
{!showBaby && (
<div
className="absolute inset-0 transition-opacity"
style={{
transitionDuration: '3000ms',
background: `radial-gradient(ellipse at 50% 50%, ${eggColor}30 0%, transparent 60%)`,
opacity: (isEggPhase || isHatching) ? 0.07 : 0.05,
}}
/>
)}
{/* ── Floating particles (egg phase) ── */}
{isEggPhase && (
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="absolute rounded-full"
style={{
width: 2 + (i % 3),
height: 2 + (i % 3),
left: `${20 + (i * 12) % 60}%`,
bottom: '40%',
backgroundColor: `${eggColor}40`,
animation: `onboard-particle-rise ${4 + i * 0.7}s ease-out ${i * 0.8}s infinite`,
}}
/>
))}
</div>
)}
{/* ── The Egg ── */}
{(isEggPhase || isHatching) && eggCompanion && (
<div className="absolute inset-0 flex items-center justify-center">
<div
ref={eggContainerRef}
className={cn(
'cursor-pointer relative',
eggVisible ? '' : 'opacity-0',
eggVisible && isEggPhase && 'animate-egg-onboard-breathe',
isHatching && 'animate-egg-onboard-burst',
)}
onClick={isEggPhase ? handleEggClick : undefined}
>
<div
className="absolute -inset-12 rounded-full blur-2xl transition-opacity duration-1000"
style={{
background: `radial-gradient(circle, ${eggColor}50 0%, transparent 70%)`,
opacity: phase === 'crack_3' ? 0.5 : phase === 'crack_2' ? 0.35 : phase === 'crack_1' ? 0.25 : 0.15,
}}
/>
<BlobbiStageVisual
companion={eggCompanion}
size="lg"
animated
className="size-56 sm:size-64 md:size-72"
tourVisualState={tourVisualState}
/>
</div>
</div>
)}
{/* ── Screen flash ── */}
{showFlash && (
<div
className="absolute inset-0 bg-white animate-onboard-screen-flash pointer-events-none"
style={{ zIndex: 80 }}
/>
)}
{/* ── Hatched baby blobbi with golden incandescence ── */}
{showBaby && babyCompanion && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{ paddingBottom: '18%' }}
>
{/* Rotating golden incandescence */}
<div className={cn(
'absolute animate-onboard-golden-fadein',
blobbiVisible ? '' : 'opacity-0',
)}>
<div
className="animate-onboard-golden-rotate"
style={{
width: 900,
height: 900,
background: `conic-gradient(
from 0deg,
rgba(255, 250, 230, 0.18) 0deg,
rgba(255, 245, 210, 0.50) 50deg,
rgba(255, 250, 235, 0.22) 100deg,
rgba(255, 248, 220, 0.15) 150deg,
rgba(255, 245, 210, 0.48) 210deg,
rgba(255, 250, 230, 0.20) 270deg,
rgba(255, 248, 220, 0.15) 320deg,
rgba(255, 250, 230, 0.18) 360deg
)`,
borderRadius: '50%',
filter: 'blur(30px)',
}}
/>
</div>
{/* Bright white-gold shine directly behind blobbi */}
<div
className={cn(
'absolute rounded-full transition-opacity duration-1000',
blobbiVisible ? 'opacity-100' : 'opacity-0',
)}
style={{
width: 320,
height: 320,
background: 'radial-gradient(circle, rgba(255,255,245,0.70) 0%, rgba(255,250,225,0.30) 40%, transparent 70%)',
}}
/>
{/* Wider golden halo */}
<div
className={cn(
'absolute rounded-full transition-opacity [transition-duration:2000ms]',
blobbiVisible ? 'opacity-100' : 'opacity-0',
)}
style={{
width: 700,
height: 700,
background: 'radial-gradient(circle, rgba(255, 248, 210, 0.40) 0%, rgba(255, 240, 190, 0.18) 40%, transparent 65%)',
filter: 'blur(15px)',
}}
/>
{/* ── Sparkles everywhere ── */}
{/* Inner ring - bright twinkling sparkles */}
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i / 20) * Math.PI * 2;
const r = 80 + (i % 4) * 35;
const size = 4 + (i % 3) * 3;
return (
<div
key={`inner-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
borderRadius: '50%',
background: i % 2 === 0
? 'radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.4) 40%, transparent 70%)'
: 'radial-gradient(circle, rgba(255,240,130,1) 0%, rgba(255,220,80,0.3) 50%, transparent 70%)',
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
}}
/>
);
})}
{/* Outer ring - larger, slower sparkles */}
{Array.from({ length: 16 }).map((_, i) => {
const angle = (i / 16) * Math.PI * 2 + 0.3;
const r = 170 + (i % 3) * 50;
const size = 5 + (i % 4) * 3;
return (
<div
key={`outer-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
borderRadius: '50%',
background: i % 3 === 0
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
: 'radial-gradient(circle, rgba(255,235,120,0.85) 0%, transparent 60%)',
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.25}s infinite`,
}}
/>
);
})}
{/* Scattered wide-field sparkles */}
{Array.from({ length: 24 }).map((_, i) => {
const x = (Math.sin(i * 2.7 + 1.3) * 0.5 + 0.5) * 80 + 10;
const y = (Math.cos(i * 3.1 + 0.7) * 0.5 + 0.5) * 70 + 10;
const size = 3 + (i % 3) * 2;
return (
<div
key={`field-${i}`}
className="absolute"
style={{
width: size,
height: size,
left: `${x}%`,
top: `${y}%`,
borderRadius: '50%',
background: i % 4 === 0
? 'radial-gradient(circle, rgba(255,255,255,0.95) 0%, transparent 70%)'
: 'radial-gradient(circle, rgba(255,240,160,0.8) 0%, transparent 70%)',
animation: `onboard-sparkle-twinkle ${2 + (i % 7) * 0.6}s ease-in-out ${i * 0.18}s infinite`,
}}
/>
);
})}
{/* Drifting light motes rising from below */}
{Array.from({ length: 10 }).map((_, i) => {
const x = (Math.sin(i * 1.9) * 0.5 + 0.5) * 70 + 15;
return (
<div
key={`drift-${i}`}
className="absolute"
style={{
width: 5 + (i % 3) * 3,
height: 5 + (i % 3) * 3,
left: `${x}%`,
bottom: '20%',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(255,250,200,0.9) 0%, rgba(255,230,120,0.3) 50%, transparent 100%)',
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.5}s infinite`,
}}
/>
);
})}
{/* The baby blobbi */}
<div className={cn(
'relative transition-opacity duration-1000',
blobbiVisible ? 'opacity-100' : 'opacity-0',
)}>
<BlobbiStageVisual
companion={babyCompanion}
size="lg"
animated
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
/>
</div>
</div>
)}
{/* ── Dialog text (no box, blur behind) ── */}
{phase === 'dialog' && (
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
<div className="relative max-w-md w-full text-center">
{/* Soft feathered backdrop with shadow */}
<div
className="absolute -inset-32"
style={{
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
}}
/>
{/* Speaker */}
<div className="relative">
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
???
</p>
{/* Typewriter text */}
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[3em]">
{dialogTypewriter.displayed}
{!dialogTypewriter.done && (
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
)}
</p>
{/* Advance indicator */}
{dialogTypewriter.done && (
<div className="mt-4 animate-onboard-continue-pulse">
<span className="text-xs text-white/30">&#9660;</span>
</div>
)}
</div>
</div>
</div>
)}
{/* ── Naming ── */}
{phase === 'naming' && (
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
<div className={cn(
'relative max-w-md w-full text-center',
namingVisible ? 'animate-onboard-soft-fade-in' : 'opacity-0',
)}>
{/* Soft feathered backdrop with shadow */}
<div
className="absolute -inset-32"
style={{
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
}}
/>
<div className="relative">
{/* Speaker */}
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
???
</p>
{/* Typewriter question */}
<p className="text-base sm:text-lg text-white/85 leading-relaxed font-light mb-6 min-h-[1.5em] whitespace-pre-line">
{namingTypewriter.displayed}
{!namingTypewriter.done && (
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
)}
</p>
{/* Input + confirm (appear after typewriter done) */}
{namingTypewriter.done && (
<div className="space-y-3 animate-onboard-soft-fade-in">
<Input
ref={nameInputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="..."
maxLength={32}
autoFocus
className={cn(
'text-center text-lg font-light h-12',
'bg-white/10 border-transparent text-white placeholder:text-white/30',
'focus:bg-white/[0.25] focus:border-transparent focus:ring-0 focus:outline-none',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'focus:shadow-[0_0_15px_rgba(255,255,255,0.15),0_0_40px_rgba(255,250,230,0.08)]',
'transition-all duration-300',
'rounded-full transition-shadow duration-500',
)}
onKeyDown={(e) => {
if (e.key === 'Enter' && name.trim()) handleNameSubmit();
}}
/>
{name.trim() && (
<Button
onClick={handleNameSubmit}
disabled={isNaming}
className={cn(
'max-w-[12rem] mx-auto h-10 px-8 text-sm font-light tracking-wide',
'bg-white/15 hover:bg-white/22 text-white/80 border-transparent',
'rounded-full transition-all duration-300',
'focus-visible:ring-0 focus-visible:ring-offset-0',
)}
variant="ghost"
>
That&apos;s the one.
</Button>
)}
</div>
)}
</div>
</div>
</div>
)}
{/* ── Fade to white on completion ── */}
{fadeOut && (
<div
className="absolute inset-0 bg-white pointer-events-none"
style={{
zIndex: 90,
animation: 'blobbi-fade-to-white 2s ease-in forwards',
}}
/>
)}
</div>
);
}
@@ -1,32 +1,19 @@
/**
* BlobbiOnboardingFlow - Main component that orchestrates the onboarding steps
*
* This component renders the appropriate onboarding step based on the user's
* actual profile state. The initial step is derived from whether the profile
* exists - not hardcoded.
*
* MODES:
* 1. Full onboarding (default): Auto profile creation → Adoption question → Preview
* 2. Adoption only (adoptionOnly=true): Skip directly to Preview for existing profiles
*
* IMPORTANT: This component should only be rendered when:
* - User has no profile (auto-creates profile using kind 0 name)
* - User has profile but no pets (shows adoption)
* - User wants to adopt another Blobbi (adoptionOnly mode)
*
* Profile creation is now automatic - no manual name entry step is needed.
* BlobbiOnboardingFlow - Immersive hatching ceremony for every new Blobbi
*
* Every new egg goes through the hatching ceremony - whether it's a user's
* first Blobbi or their tenth. The ceremony creates the egg silently in the
* background and presents a wordless, emotional hatching experience.
*
* The `adoptionOnly` prop is accepted for API compatibility but no longer
* changes the flow - every egg gets the full ceremony.
*/
import { useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useBlobbiOnboarding } from '../hooks/useBlobbiOnboarding';
import { BlobbiAdoptionStep } from './BlobbiAdoptionStep';
import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
import { Loader2 } from 'lucide-react';
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
interface BlobbiOnboardingFlowProps {
/** Current profile (null if doesn't exist) */
@@ -43,9 +30,11 @@ interface BlobbiOnboardingFlowProps {
setStoredSelectedD: (d: string) => void;
/** Called when onboarding is complete */
onComplete?: () => void;
/**
* If true, skip profile creation and adoption question, go directly to preview.
* Use this for "Adopt another Blobbi" flow for existing users.
/** If provided, skip egg creation and use this existing egg for the ceremony. */
existingCompanion?: BlobbiCompanion | null;
/**
* Accepted for API compatibility. Every new egg goes through the ceremony.
* @deprecated No longer changes the flow.
*/
adoptionOnly?: boolean;
}
@@ -58,98 +47,20 @@ export function BlobbiOnboardingFlow({
invalidateCompanion,
setStoredSelectedD,
onComplete,
adoptionOnly = false,
existingCompanion,
adoptionOnly,
}: BlobbiOnboardingFlowProps) {
const [showAdoptConfirmDialog, setShowAdoptConfirmDialog] = useState(false);
const {
state,
actions,
coins,
} = useBlobbiOnboarding({
profile,
updateProfileEvent,
updateCompanionEvent,
invalidateProfile,
invalidateCompanion,
setStoredSelectedD,
onComplete,
adoptionOnly,
});
// Debug logging
console.log('[BlobbiOnboardingFlow] Rendering:', {
hasProfile: !!profile,
profileName: profile?.name,
step: state.step,
hasPreview: !!state.preview,
adoptionOnly,
});
// Handle adopt button click - show confirmation dialog
const handleAdoptClick = () => {
setShowAdoptConfirmDialog(true);
};
// Handle confirm adoption
const handleConfirmAdopt = async () => {
await actions.adoptPreview();
setShowAdoptConfirmDialog(false);
};
// ─── Step: Auto Profile Creation ──────────────────────────────────────────────
// Shows a loading state while profile is being auto-created
if (state.step === 'creating-profile') {
return (
<div className="flex flex-col items-center justify-center min-h-[300px] gap-4 p-8">
<Loader2 className="size-10 text-primary animate-spin" />
<p className="text-muted-foreground text-center">
Setting up your profile...
</p>
</div>
);
}
// ─── Step: Adoption Question ──────────────────────────────────────────────────
// Shown when profile exists but user has no pets yet
if (state.step === 'adoption-question') {
return (
<BlobbiAdoptionStep
blobbonautName={state.blobbonautName || profile?.name}
onStartAdoption={actions.startAdoptionPreview}
/>
);
}
// ─── Step: Egg Preview ────────────────────────────────────────────────────────
// Shown when user is previewing/choosing an egg to adopt
if (state.step === 'preview' && state.preview) {
return (
<>
<BlobbiEggPreviewCard
preview={state.preview}
coins={coins}
isFirstPreview={state.isFirstPreview}
isProcessing={state.isProcessing}
actionInProgress={state.actionInProgress === 'reroll' ? 'reroll' : state.actionInProgress === 'adopt' ? 'adopt' : null}
onReroll={actions.rerollPreview}
onAdopt={handleAdoptClick}
onNameChange={actions.setPreviewName}
/>
<BlobbiAdoptionConfirmDialog
open={showAdoptConfirmDialog}
onOpenChange={setShowAdoptConfirmDialog}
preview={state.preview}
coins={coins}
isAdopting={state.isProcessing && state.actionInProgress === 'adopt'}
onConfirm={handleConfirmAdopt}
/>
</>
);
}
// Fallback (shouldn't happen if parent logic is correct)
console.warn('[BlobbiOnboardingFlow] Unexpected state - no matching step');
return null;
return (
<BlobbiHatchingCeremony
profile={profile}
updateProfileEvent={updateProfileEvent}
updateCompanionEvent={updateCompanionEvent}
invalidateProfile={invalidateProfile}
invalidateCompanion={invalidateCompanion}
setStoredSelectedD={setStoredSelectedD}
onComplete={onComplete}
existingCompanion={existingCompanion}
eggOnly={adoptionOnly}
/>
);
}
@@ -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);
+5 -9
View File
@@ -1,19 +1,15 @@
/**
* Blobbi Onboarding Module
*
* Provides components and hooks for the Blobbi onboarding flow:
* 1. Auto profile creation (using kind 0 name)
* 2. Adoption question
* 3. Egg preview with reroll/adopt
*
* Every new egg goes through the immersive hatching ceremony:
* dark screen, huge egg, click-to-hatch, sentimental birth reveal, naming.
*/
// Components
export { BlobbiAdoptionStep } from './components/BlobbiAdoptionStep';
export { BlobbiEggPreviewCard } from './components/BlobbiEggPreviewCard';
export { BlobbiAdoptionConfirmDialog } from './components/BlobbiAdoptionConfirmDialog';
export { BlobbiOnboardingFlow } from './components/BlobbiOnboardingFlow';
export { BlobbiHatchingCeremony } from './components/BlobbiHatchingCeremony';
// Hooks
// Hooks (used internally; kept exported for potential external use)
export { useBlobbiOnboarding } from './hooks/useBlobbiOnboarding';
export type {
OnboardingStep,
+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>
);
+102 -178
View File
@@ -1,50 +1,31 @@
/**
* BlobbiPhotoModal - Modal for taking and sharing Blobbi photos
* BlobbiPhotoModal - Fullscreen photo overlay
*
* Features:
* - Polaroid-style preview of the Blobbi
* - Download as PNG
* - Post to Nostr with Blossom upload
*
* Uses html-to-image for DOM-to-PNG conversion.
* Simple blurred overlay with the polaroid photo centered,
* and download/share buttons below. Tap outside to close.
*/
import { useState, useRef, useCallback } from 'react';
import { toPng } from 'html-to-image';
import { Download, Send, Loader2, Camera } from 'lucide-react';
import { Download, Share2, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { BlobbiPolaroidCard } from './BlobbiPolaroidCard';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import { openUrl } from '@/lib/downloadFile';
import { trackDailyMissionProgress } from '@/blobbi/actions';
import { cn } from '@/lib/utils';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
// ─── Types ────────────────────────────────────────────────────────────────────
import { Capacitor } from '@capacitor/core';
export interface BlobbiPhotoModalProps {
/** Whether the modal is open */
open: boolean;
/** Callback when the modal should close */
onOpenChange: (open: boolean) => void;
/** The Blobbi companion to photograph */
companion: BlobbiCompanion;
}
// ─── Utility Functions ────────────────────────────────────────────────────────
/**
* Convert a data URL to a File object
*/
function dataUrlToFile(dataUrl: string, filename: string): File {
const arr = dataUrl.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png';
@@ -57,218 +38,161 @@ function dataUrlToFile(dataUrl: string, filename: string): File {
return new File([u8arr], filename, { type: mime });
}
/**
* Trigger a file download in the browser
*/
function downloadFile(dataUrl: string, filename: string): void {
const link = document.createElement('a');
link.download = filename;
link.href = dataUrl;
link.click();
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiPhotoModal({
open,
onOpenChange,
companion,
}: BlobbiPhotoModalProps) {
const polaroidRef = useRef<HTMLDivElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [isPosting, setIsPosting] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isSharing, setIsSharing] = useState(false);
const { user } = useCurrentUser();
const { mutateAsync: uploadFile } = useUploadFile();
const { mutateAsync: createEvent } = useNostrPublish();
/**
* Generate PNG from the polaroid card
*/
const generateImage = useCallback(async (): Promise<string | null> => {
if (!polaroidRef.current) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Could not capture the photo. Please try again.',
});
return null;
}
if (!polaroidRef.current) return null;
try {
// Use html-to-image with high quality settings
const dataUrl = await toPng(polaroidRef.current, {
return await toPng(polaroidRef.current, {
quality: 1.0,
pixelRatio: 2, // 2x for retina displays
pixelRatio: 2,
cacheBust: true,
// Skip external fonts that might fail to load
skipFonts: true,
});
return dataUrl;
} catch (error) {
console.error('[BlobbiPhotoModal] Failed to generate image:', error);
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to generate the photo. Please try again.',
});
console.error('[BlobbiPhoto] Failed to generate image:', error);
toast({ variant: 'destructive', title: 'Error', description: 'Failed to capture photo.' });
return null;
}
}, []);
/**
* Handle download action
*/
const handleDownload = useCallback(async () => {
setIsGenerating(true);
setIsDownloading(true);
try {
const dataUrl = await generateImage();
if (dataUrl) {
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`;
downloadFile(dataUrl, filename);
toast({
title: 'Photo saved!',
description: 'Your Blobbi photo has been downloaded.',
});
if (!dataUrl) return;
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-photo.png`;
if (Capacitor.isNativePlatform()) {
// On native, use the download utility which handles share sheet
const blob = dataUrlToFile(dataUrl, filename);
const url = URL.createObjectURL(blob);
await openUrl(url);
URL.revokeObjectURL(url);
} else {
const link = document.createElement('a');
link.download = filename;
link.href = dataUrl;
link.click();
}
toast({ title: 'Photo saved!' });
} finally {
setIsGenerating(false);
setIsDownloading(false);
}
}, [generateImage, companion.name]);
/**
* Handle post action - upload to Blossom and create Nostr post
*/
const handlePost = useCallback(async () => {
if (!user) {
toast({
variant: 'destructive',
title: 'Not logged in',
description: 'Please log in to post your Blobbi photo.',
});
return;
}
setIsPosting(true);
const handleShare = useCallback(async () => {
if (!user) return;
setIsSharing(true);
try {
// Generate the image
const dataUrl = await generateImage();
if (!dataUrl) {
return;
}
if (!dataUrl) return;
// Convert to File for upload
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`;
const file = dataUrlToFile(dataUrl, filename);
// Upload to Blossom - returns NIP-94 compatible tags
const tags = await uploadFile(file);
// Extract URL from the 'url' tag (NIP-94 format)
// The upload hook returns tags like [['url', '...'], ['m', '...'], ['x', '...'], ...]
const urlTag = tags.find((tag) => tag[0] === 'url');
if (!urlTag || !urlTag[1]) {
throw new Error('Upload succeeded but no URL was returned');
}
if (!urlTag?.[1]) throw new Error('Upload succeeded but no URL returned');
const url = urlTag[1];
// Build imeta tag from all NIP-94 tags
// Format: ['imeta', 'url https://...', 'm image/png', 'x abc123', ...]
const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`);
// Create the post content
const content = `${companion.name} ${url}`;
// Publish kind 1 event
await createEvent({
kind: 1,
content,
content: `${companion.name} ${url}`,
tags: [['imeta', ...imetaFields]],
});
toast({
title: 'Posted!',
description: 'Your Blobbi photo has been shared.',
});
// Track daily mission progress for photo action
toast({ title: 'Posted!', description: 'Your Blobbi photo has been shared.' });
trackDailyMissionProgress('take_photo', 1, user.pubkey);
// Close the modal after successful post
onOpenChange(false);
} catch (error) {
console.error('[BlobbiPhotoModal] Failed to post:', error);
toast({
variant: 'destructive',
title: 'Failed to post',
description: error instanceof Error ? error.message : 'Please try again.',
});
console.error('[BlobbiPhoto] Failed to share:', error);
toast({ variant: 'destructive', title: 'Failed to post', description: error instanceof Error ? error.message : 'Please try again.' });
} finally {
setIsPosting(false);
setIsSharing(false);
}
}, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]);
const isProcessing = isGenerating || isPosting;
if (!open) return null;
const isProcessing = isDownloading || isSharing;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Camera className="size-5" />
Take a Photo
</DialogTitle>
<DialogDescription>
Capture a polaroid-style photo of {companion.name}
</DialogDescription>
</DialogHeader>
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center">
{/* Backdrop — tap to close */}
<div
className="absolute inset-0 bg-background/60 backdrop-blur-sm"
onClick={() => !isProcessing && onOpenChange(false)}
/>
{/* Polaroid preview - centered */}
<div className="flex justify-center py-4">
<BlobbiPolaroidCard
ref={polaroidRef}
companion={companion}
showStage
/>
</div>
{/* Close button — top-right of the container */}
<button
onClick={() => !isProcessing && onOpenChange(false)}
className="absolute top-3 right-3 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-5" />
</button>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row gap-3">
<Button
variant="outline"
onClick={handleDownload}
{/* Polaroid card */}
<div className="relative z-10 animate-in fade-in zoom-in-95 duration-200">
<BlobbiPolaroidCard
ref={polaroidRef}
companion={companion}
showStage
/>
</div>
{/* Action buttons */}
<div className="relative z-10 flex items-center gap-6 mt-8">
<button
onClick={handleDownload}
disabled={isProcessing}
className={cn(
'flex flex-col items-center gap-1.5 transition-all duration-200',
'hover:scale-110 active:scale-95',
isProcessing && 'opacity-50 pointer-events-none',
)}
>
<div className="size-14 rounded-full flex items-center justify-center text-sky-500" style={{
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #0ea5e9 25%, transparent), color-mix(in srgb, #0ea5e9 10%, transparent) 70%)',
}}>
{isDownloading ? <Loader2 className="size-6 animate-spin" /> : <Download className="size-6" />}
</div>
<span className="text-xs font-medium text-muted-foreground">Save</span>
</button>
{user && (
<button
onClick={handleShare}
disabled={isProcessing}
className="flex-1"
>
{isGenerating ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Download className="size-4 mr-2" />
className={cn(
'flex flex-col items-center gap-1.5 transition-all duration-200',
'hover:scale-110 active:scale-95',
isProcessing && 'opacity-50 pointer-events-none',
)}
Download
</Button>
<Button
onClick={handlePost}
disabled={isProcessing || !user}
className="flex-1"
>
{isPosting ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Send className="size-4 mr-2" />
)}
Post
</Button>
</div>
{/* Login hint if not logged in */}
{!user && (
<p className="text-sm text-muted-foreground text-center">
Log in to post your Blobbi photo
</p>
<div className="size-14 rounded-full flex items-center justify-center text-violet-500" style={{
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #8b5cf6 25%, transparent), color-mix(in srgb, #8b5cf6 10%, transparent) 70%)',
}}>
{isSharing ? <Loader2 className="size-6 animate-spin" /> : <Share2 className="size-6" />}
</div>
<span className="text-xs font-medium text-muted-foreground">Post</span>
</button>
)}
</DialogContent>
</Dialog>
</div>
</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));
}
+1 -1
View File
@@ -45,7 +45,7 @@ export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi {
size: companion.visualTraits.size,
// Metadata
seed: companion.seed,
tags: companion.allTags,
tags: companion.allTags ?? [],
// Adult-specific data (for adult form resolution)
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
};
+103 -42
View File
@@ -1,12 +1,13 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { ExternalLink, GitFork, Package } from 'lucide-react';
import { ExternalLink, GitFork, Package, Play } 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 { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useAddrEvent } from '@/hooks/useEvent';
import { NostrURI } from '@/lib/NostrURI';
import { cn } from '@/lib/utils';
@@ -66,6 +67,31 @@ function getShakespeareUrl(tags: string[][]): string | undefined {
return undefined;
}
interface NsiteRef {
/** The author pubkey (hex) of the kind 35128 event. */
pubkey: string;
/** The d-tag identifier of the kind 35128 event. */
identifier: string;
}
/**
* Extract nsite info from a kind 35128 `a` tag, if present.
* The `a` tag value format is `"35128:<pubkey>:<d-tag>"`.
*/
function getNsiteRef(tags: string[][]): NsiteRef | undefined {
for (const tag of tags) {
if (tag[0] !== 'a') continue;
const parts = tag[1]?.split(':');
if (!parts || parts[0] !== '35128' || parts.length < 3) continue;
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
if (!pubkey || !identifier) continue;
return { pubkey, identifier };
}
return undefined;
}
interface AppHandlerContentProps {
event: NostrEvent;
/** If true, show compact preview (used in NoteCard feed). */
@@ -79,42 +105,40 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
const about = metadata.about;
const picture = metadata.picture;
const banner = metadata.banner;
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
const nsiteRef = useMemo(() => getNsiteRef(event.tags), [event.tags]);
const [previewOpen, setPreviewOpen] = useState(false);
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
const thumbnailUrl = preview?.thumbnail_url;
const [imgError, setImgError] = useState(false);
const showThumbnail = thumbnailUrl && !imgError;
// Fetch the actual nsite event so we can serve files directly from Blossom.
const { data: nsiteEvent } = useAddrEvent(
nsiteRef ? { kind: 35128, pubkey: nsiteRef.pubkey, identifier: nsiteRef.identifier } : undefined,
);
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) && (
{/* Banner hero */}
{banner && (
<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)}
/>
)}
<img
src={banner}
alt=""
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</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'}>
<div className="relative px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={banner ? '-mt-7' : 'pt-3.5'}>
{picture ? (
<img
src={picture}
@@ -166,8 +190,19 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
{/* Actions */}
<div className="flex items-center gap-2">
{nsiteRef && (
<Button
size="sm"
className="h-7 text-xs"
disabled={!nsiteEvent}
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3 mr-1" />
Run
</Button>
)}
{websiteUrl && (
<Button asChild size="sm" className="h-7 text-xs">
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'} 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" />
@@ -186,36 +221,42 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
</div>
</div>
</div>
);
{nsiteRef && nsiteEvent && (
<NsitePreviewDialog
event={nsiteEvent}
appName={name}
appPicture={picture}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
)}
</>
);
}
// 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) && (
{/* Banner hero */}
{banner && (
<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)}
/>
)}
<img
src={banner}
alt=""
className="size-full object-cover"
loading="lazy"
/>
</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="relative px-4 pb-4 space-y-3">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={cn(
'flex items-end justify-between',
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
banner ? '-mt-10' : 'pt-4',
)}>
{picture ? (
<img
@@ -268,8 +309,18 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
{nsiteRef && (
<Button
size="sm"
disabled={!nsiteEvent}
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3.5 mr-1.5" />
Run
</Button>
)}
{websiteUrl && (
<Button asChild size="sm">
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'}>
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
Open App
<ExternalLink className="size-3 ml-1.5" />
@@ -287,6 +338,16 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
</div>
</div>
</div>
{nsiteRef && nsiteEvent && (
<NsitePreviewDialog
event={nsiteEvent}
appName={name}
appPicture={picture}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
)}
</div>
);
}
+84 -14
View File
@@ -6,7 +6,7 @@ import {
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
Users, Zap,
Users, Vote, Zap,
} from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -22,6 +22,7 @@ import { ExternalFavicon } from '@/components/ExternalFavicon';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
import { useAuthor } from '@/hooks/useAuthor';
import { useBookInfo } from '@/hooks/useBookInfo';
import { useLinkPreview } from '@/hooks/useLinkPreview';
@@ -44,26 +45,38 @@ interface CommentRoot {
identifier?: string;
/** Root kind number (from K tag). */
rootKind?: string;
/** Relay URL hint from the E or A tag (position [2]). */
relayHint?: string;
/** Author pubkey hint extracted from the E tag (position [3]) or P tag. */
authorHint?: string;
}
/** Parse the root reference from a kind 1111 comment's tags. */
function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
const aTag = event.tags.find(([name]) => name === 'A')?.[1];
const aTagFull = event.tags.find(([name]) => name === 'A');
// Use find (not findLast) to get the root E tag, not a parent e tag
const eTag = event.tags.find(([name]) => name === 'E')?.[1];
const eTagFull = event.tags.find(([name]) => name === 'E');
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
const kTag = event.tags.find(([name]) => name === 'K')?.[1];
// P tag holds the root event author's pubkey — used as author hint fallback
const pTag = event.tags.find(([name]) => name === 'P')?.[1];
if (aTag) {
if (aTagFull) {
const aTag = aTagFull[1];
const relayHint = aTagFull[2] || undefined;
const parts = aTag.split(':');
const kind = parseInt(parts[0], 10);
const pubkey = parts[1] ?? '';
const identifier = parts.slice(2).join(':');
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag };
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag, relayHint };
}
if (eTag) {
return { type: 'event', eventId: eTag, rootKind: kTag };
if (eTagFull) {
const eTag = eTagFull[1];
const relayHint = eTagFull[2] || undefined;
// NIP-22 E tags may have the author pubkey at position [3]; fall back to P tag
const authorHint = eTagFull[3] || pTag || undefined;
return { type: 'event', eventId: eTag, rootKind: kTag, relayHint, authorHint };
}
if (iTag) {
@@ -91,6 +104,7 @@ const KIND_LABELS: Record<number, string> = {
22: 'a short video',
62: 'a request to vanish',
1063: 'a file',
1018: 'a vote',
1068: 'a poll',
1111: 'a comment',
1222: 'a voice message',
@@ -108,7 +122,8 @@ const KIND_LABELS: Record<number, string> = {
30030: 'an emoji pack',
30054: 'a podcast episode',
30055: 'a podcast trailer',
30063: 'a release',
3063: 'a Zapstore asset',
30063: 'a Zapstore release',
30311: 'a stream',
30315: 'a status',
30617: 'a repository',
@@ -116,7 +131,7 @@ const KIND_LABELS: Record<number, string> = {
31922: 'a calendar event',
31923: 'a calendar event',
31990: 'an app',
32267: 'an app',
32267: 'a Zapstore app',
34139: 'a playlist',
34236: 'a divine',
34550: 'a community',
@@ -141,6 +156,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
21: Film,
22: Film,
1063: FileText,
1018: Vote,
1068: BarChart3,
1222: Mic,
1617: FileText,
@@ -155,6 +171,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
30030: SmilePlus,
30054: Podcast,
30055: Podcast,
3063: Package,
30063: Package,
30311: Radio,
30617: GitBranch,
@@ -212,10 +229,11 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
34139: 'playlist',
};
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto Zapstore app"). */
const KIND_POSTFIXES: Partial<Record<number, string>> = {
32267: 'on Zapstore',
30063: 'release',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/** Get a display name for an event based on its kind and tags. */
@@ -485,7 +503,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
/** Comment context for non-profile addressable event roots (A tag). */
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useAddrEvent(root.addr);
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
@@ -523,18 +541,33 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
/** Comment context for regular event roots (E tag). */
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const { data: event, isLoading } = useEvent(root.eventId);
const { data: event, isLoading } = useEvent(
root.eventId,
root.relayHint ? [root.relayHint] : undefined,
root.authorHint,
);
// Kind 7 reactions get special treatment
if (event?.kind === 7) {
return <ReactionCommentContext event={event} className={className} />;
}
// Kind 1018 poll votes get special treatment
if (event?.kind === 1018) {
return <PollVoteCommentContext event={event} className={className} />;
}
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
const link = event ? getRootLink(event) : undefined;
const hoverContent = root.eventId ? (
<EmbeddedNote eventId={root.eventId} className="border-0 rounded-none" disableHoverCards />
<EmbeddedNote
eventId={root.eventId}
relays={root.relayHint ? [root.relayHint] : undefined}
authorHint={root.authorHint}
className="border-0 rounded-none"
disableHoverCards
/>
) : undefined;
return (
@@ -583,6 +616,43 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
);
}
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const voteLink = getRootLink(event);
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
const voteLabel = usePollVoteLabel(event);
return (
<CommentContextRow prefix="Commenting on" className={className}>
{author.isLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileLink}
className="text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
@{displayName}
</Link>
</ProfileHoverCard>
)}
<Link
to={voteLink}
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<Vote className="size-3.5 shrink-0" />
{voteLabel ? `vote for ${voteLabel}` : 'vote'}
</Link>
</CommentContextRow>
);
}
/** Comment context for external content roots (I tag). */
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
const identifier = root.identifier ?? '';
+96 -87
View File
@@ -20,6 +20,7 @@ import { GifPicker } from '@/components/GifPicker';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { NoteContent } from '@/components/NoteContent';
@@ -201,7 +202,6 @@ export function ComposeBox({
// Poll mode state
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState([
{ id: pollOptionId(), label: '' },
{ id: pollOptionId(), label: '' },
@@ -233,7 +233,6 @@ export function ComposeBox({
setTrayOpen(false);
setInternalPreviewMode(false);
setMode(initialMode);
setPollQuestion('');
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
setPollType('singlechoice');
setPollDuration(7);
@@ -982,7 +981,8 @@ export function ComposeBox({
const handlePollSubmit = async () => {
const filledOptions = pollOptions.filter((o) => o.label.trim());
if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return;
const finalContent = content.trim();
if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return;
const tags: string[][] = [];
for (const opt of filledOptions) {
@@ -992,10 +992,27 @@ export function ComposeBox({
if (pollDuration > 0) {
tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]);
}
tags.push(['alt', `Poll: ${pollQuestion.trim()}`]);
// NIP-92: Add imeta tags for media URLs in content
const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'));
const processedUrls = new Set<string>();
for (const match of mediaUrlMatches) {
const url = match[0];
if (processedUrls.has(url)) continue;
processedUrls.add(url);
const fileTags = uploadedFileGroups.get(url);
if (fileTags) {
tags.push(['imeta', ...fileTags.map(tag => `${tag[0]} ${tag[1]}`)]);
} else {
const ext = match[1].toLowerCase();
tags.push(['imeta', `url ${url}`, `m ${mimeFromExt(ext)}`]);
}
}
tags.push(['alt', `Poll: ${finalContent}`]);
try {
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
await createEvent({ kind: 1068, content: finalContent, tags });
resetComposeState();
queryClient.invalidateQueries({ queryKey: ['feed'] });
toast({ title: 'Poll published!' });
@@ -1006,7 +1023,7 @@ export function ComposeBox({
};
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2;
const isPollValid = content.trim().length > 0 && pollFilledCount >= 2;
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
@@ -1014,7 +1031,7 @@ export function ComposeBox({
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3 bg-background/85")}>
<div className={cn("px-4 py-3 bg-background/85 rounded-2xl")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
@@ -1062,31 +1079,83 @@ export function ComposeBox({
)}
<div className="flex-1 min-w-0">
{mode === 'poll' ? (
/* ── Inline poll builder ─────────────────────────────── */
<div className="pt-2.5 pb-1 space-y-3">
{!previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
</div>
);
})()
)}
{/* Poll options + settings — shown below the normal textarea/preview */}
{mode === 'poll' && (
<div className="space-y-3 pt-1">
{/* Back to post link — hidden when poll mode is the only mode */}
{initialMode !== 'poll' && (
<button
type="button"
onClick={() => setMode('post')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-3.5" />
Back to post
</button>
)}
{/* Question */}
<textarea
value={pollQuestion}
onChange={(e) => setPollQuestion(e.target.value)}
placeholder="Ask a question…"
rows={2}
maxLength={280}
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
/>
{/* Options */}
<div className="space-y-1.5">
{pollOptions.map((opt, idx) => (
@@ -1167,66 +1236,6 @@ export function ComposeBox({
))}
</div>
</div>
) : !previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
</div>
);
})()
)}
{/* Content warning input */}
@@ -1258,7 +1267,7 @@ export function ComposeBox({
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
}} />
) : (
<EmbeddedNote eventId={quotedEvent.id} />
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
)}
</div>
)}
@@ -1479,11 +1488,11 @@ export function ComposeBox({
}}
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
>
<img
src={emoji.url}
alt={emoji.shortcode}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
</button>
))}
</div>
+18 -1
View File
@@ -1,8 +1,11 @@
import type { ReactNode } from 'react';
import { type ReactNode, useCallback, useState } from 'react';
import { isCustomEmoji, getCustomEmojiUrl, buildEmojiMap, type ResolvedEmoji } from '@/lib/customEmoji';
import { cn } from '@/lib/utils';
/** Threshold at or below which we apply nearest-neighbor scaling. */
const PIXEL_ART_MAX = 16;
interface CustomEmojiImgProps {
/** The shortcode name (without colons). */
name: string;
@@ -14,16 +17,30 @@ interface CustomEmojiImgProps {
/**
* Renders a single custom emoji as an inline image.
*
* If the image's natural dimensions are 16x16 or smaller, nearest-neighbor
* (`image-rendering: pixelated`) scaling is applied to preserve crisp pixels.
*/
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
const [pixelated, setPixelated] = useState(false);
const handleLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
if (img.naturalWidth > 0 && img.naturalWidth <= PIXEL_ART_MAX && img.naturalHeight <= PIXEL_ART_MAX) {
setPixelated(true);
}
}, []);
return (
<img
src={url}
alt={`:${name}:`}
title={`:${name}:`}
className={className}
style={pixelated ? { imageRendering: 'pixelated' } : undefined}
loading="lazy"
decoding="async"
onLoad={handleLoad}
/>
);
}
+4 -5
View File
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { cn } from '@/lib/utils';
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
@@ -172,12 +173,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
className="group relative"
title={`:${emoji.shortcode}:`}
>
<img
src={emoji.url}
alt={`:${emoji.shortcode}:`}
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="size-8 object-contain rounded transition-transform group-hover:scale-125"
loading="lazy"
decoding="async"
/>
</div>
))}
+4 -4
View File
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { SortableList, SortableItem } from '@/components/SortableList';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -506,11 +507,10 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
>
<div className="flex items-center gap-2 pr-2 py-1.5">
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
<img
src={emoji.url}
alt={emoji.shortcode}
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="size-8 object-contain"
loading="lazy"
/>
</div>
<div className="flex-1 min-w-0">
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import data from '@emoji-mart/data';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { cn } from '@/lib/utils';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
@@ -375,11 +376,10 @@ export function EmojiShortcodeAutocomplete({
onMouseDown={(e) => e.preventDefault()}
>
{emoji.customUrl ? (
<img
src={emoji.customUrl}
alt={`:${emoji.name}:`}
<CustomEmojiImg
name={emoji.name}
url={emoji.customUrl}
className="size-5 object-contain shrink-0"
loading="lazy"
/>
) : (
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
+4 -3
View File
@@ -1083,8 +1083,9 @@ 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',
32267: 'Zapstore App',
30063: 'Zapstore Release',
3063: 'Zapstore Asset',
15128: 'Nsite',
35128: 'Nsite',
31124: 'Blobbi',
@@ -1110,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 === 31990 || 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]);
+1
View File
@@ -47,6 +47,7 @@ const LANDING_KINDS = [
30009, // Badge Definitions
10008, // Profile Badges
30008, // Profile Badges (legacy)
31124, // Blobbi
];
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
+219
View File
@@ -0,0 +1,219 @@
import { useEffect, useState } from 'react';
import { nip19 } from 'nostr-tools';
import QRCode from 'qrcode';
import { Copy, Check } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
const MIN_QR_CONTRAST = 3;
/** Saturation threshold (%) above which a color is considered "colorful". */
const COLORFUL_SAT_MIN = 15;
/** Lightness range within which a color appears visually colorful. */
const COLORFUL_L_MIN = 20;
const COLORFUL_L_MAX = 80;
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
if (typeof document === 'undefined') return null;
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
if (!raw) return null;
const { h, s, l } = parseHsl(raw);
if ([h, s, l].some(isNaN)) return null;
return { h, s, l };
}
/**
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function darkenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l > 0 && ratio < MIN_QR_CONTRAST) {
l = Math.max(0, l - 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function lightenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l < 100 && ratio < MIN_QR_CONTRAST) {
l = Math.min(100, l + 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Choose the best module color from primary and foreground.
*
* Strongly prefers primary since it carries the theme's brand identity.
* Only picks foreground if it is colorful (saturation > threshold) AND
* has significantly better contrast (> 1.5x) against the QR background.
*/
function pickModuleColor(
primary: { h: number; s: number; l: number },
foreground: { h: number; s: number; l: number } | null,
bgRgb: [number, number, number],
): { h: number; s: number; l: number } {
const fgIsColorful = foreground
&& foreground.s >= COLORFUL_SAT_MIN
&& foreground.l >= COLORFUL_L_MIN
&& foreground.l <= COLORFUL_L_MAX;
if (!fgIsColorful) return primary;
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
const fgContrast = getContrastRatio(fgRgb, bgRgb);
// Foreground must be significantly better to override primary
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
}
/**
* Derive QR module and background hex colors from the active theme.
*
* Light themes: white background, best themed color as modules (darkened if needed).
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
*
* "Best themed color" is --primary by default. If --foreground is colorful
* (saturation > 15%) and offers better contrast, it wins instead.
*/
function getThemedQRColors(): { dark: string; light: string } {
const primary = readCssHsl('--primary');
const foreground = readCssHsl('--foreground');
const background = readCssHsl('--background');
if (!primary) return { dark: '#000000', light: '#ffffff' };
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
if (!isDark) {
const white: [number, number, number] = [255, 255, 255];
const module = pickModuleColor(primary, foreground, white);
return { dark: darkenToContrast(module, white), light: '#ffffff' };
}
if (!background) return { dark: '#ffffff', light: '#000000' };
const bgRgb = hslToRgb(background.h, background.s, background.l);
const module = pickModuleColor(primary, foreground, bgRgb);
return {
dark: lightenToContrast(module, bgRgb),
light: rgbToHex(...bgRgb),
};
}
interface FollowQRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey ?? '');
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [copied, setCopied] = useState(false);
const metadata = author.data?.metadata;
const displayName = user ? metadata?.name || genUserName(user.pubkey) : '';
const npub = user ? nip19.npubEncode(user.pubkey) : '';
const followUrl = npub ? `${window.location.origin}/follow/${npub}` : '';
useEffect(() => {
if (!followUrl || !open) return;
const { dark, light } = getThemedQRColors();
QRCode.toDataURL(followUrl, {
width: 400,
margin: 2,
color: { dark, light },
errorCorrectionLevel: 'M',
})
.then(setQrDataUrl)
.catch(console.error);
}, [followUrl, open]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(followUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm p-6 flex flex-col items-center gap-5 rounded-2xl">
<DialogTitle className="sr-only">Share follow link</DialogTitle>
{/* Avatar + name */}
<div className="flex flex-col items-center gap-2">
<Avatar shape={getAvatarShape(metadata)} className="size-16 ring-2 ring-secondary">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-xl font-semibold">
{displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="text-sm text-muted-foreground text-center">
Scan to follow <span className="text-foreground font-medium">{displayName}</span>
</p>
</div>
{/* QR code */}
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="Follow QR code"
className="w-full rounded-xl border border-border"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<div className="w-full aspect-square rounded-xl border border-border bg-muted animate-pulse" />
)}
{/* Copy link */}
<button
onClick={handleCopy}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{copied
? <Check className="size-3.5 text-primary flex-shrink-0" />
: <Copy className="size-3.5 flex-shrink-0" />}
<span className="truncate max-w-64">{followUrl}</span>
</button>
</DialogContent>
</Dialog>
);
}
+5 -3
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import { cn } from '@/lib/utils';
@@ -125,7 +126,7 @@ export function ImageGallery({
))}
</div>
{/* Lightbox */}
{/* Lightbox (portals to document.body internally to escape stacking contexts) */}
{lightboxIndex !== null && lightboxIndex !== undefined && (
<Lightbox
images={images}
@@ -484,7 +485,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
(i) => i >= 0 && i < images.length,
);
return (
return createPortal(
<div
ref={containerRef}
className="fixed inset-0 z-[100] animate-in fade-in duration-200"
@@ -582,7 +583,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
{bottomBar}
</div>
)}
</div>
</div>,
document.body,
);
}
+30 -2
View File
@@ -35,7 +35,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAppContext } from "@/hooks/useAppContext";
import { useAuthors } from "@/hooks/useAuthors";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
import { useEncryptedSettings, getLocalSettingsSync } from "@/hooks/useEncryptedSettings";
import { type SyncPhase, useInitialSync } from "@/hooks/useInitialSync";
import { useLoginActions } from "@/hooks/useLoginActions";
import { useNostrPublish } from "@/hooks/useNostrPublish";
@@ -65,8 +65,12 @@ interface InitialSyncGateProps {
export function InitialSyncGate({ children }: InitialSyncGateProps) {
const { user } = useCurrentUser();
const { phase, markComplete } = useInitialSync();
const { isLoading: settingsLoading } = useEncryptedSettings();
const [preloadApp, setPreloadApp] = useState(false);
const [signupActive, setSignupActive] = useState(false);
// Track whether we've shown the app at least once so we don't re-gate on
// subsequent background refetches (e.g. window focus).
const hasShownApp = useRef(false);
const startSignup = useCallback(() => setSignupActive(true), []);
@@ -91,8 +95,10 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) {
);
}
// Don't show sync/onboarding when logged out — just show the app
// Don't show sync/onboarding when logged out — just show the app.
// Reset hasShownApp so that re-login shows the spinner until settings load.
if (!user) {
hasShownApp.current = false;
return (
<OnboardingContext.Provider value={contextValue}>
{children}
@@ -121,6 +127,28 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) {
);
}
// For returning users (phase === "complete"), decide whether to gate:
// - If we have a local lastSync timestamp, localStorage is trustworthy and
// we can render immediately. NostrSync will hot-swap any differences in
// the background once the remote settings arrive.
// - If there's NO local timestamp (e.g. localStorage was cleared, or settings
// were never synced on this browser), show the spinner until settings load
// so the user sees correct state from the start.
// Only gate on the very first load — once the app has been shown, don't
// re-gate on background refetches (e.g. window focus).
if (phase === "complete" && settingsLoading && !hasShownApp.current) {
const hasLocalSync = user ? getLocalSettingsSync(user.pubkey) > 0 : false;
if (!hasLocalSync) {
return (
<OnboardingContext.Provider value={contextValue}>
<SyncScreen phase="syncing" />
</OnboardingContext.Provider>
);
}
}
hasShownApp.current = true;
// idle or complete -> show app
return (
<OnboardingContext.Provider value={contextValue}>
+8 -1
View File
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut,
Loader2,
Loader2, QrCode,
} from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -15,6 +15,7 @@ import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import LoginDialog from '@/components/auth/LoginDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
@@ -55,6 +56,7 @@ export function LeftSidebar() {
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const { startSignup } = useOnboarding();
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
@@ -293,6 +295,10 @@ export function LeftSidebar() {
{/* Actions */}
<div className="py-1">
<button onClick={() => { setAccountPopoverOpen(false); setFollowQROpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<QrCode className="size-4 text-muted-foreground" />
<span>Share profile</span>
</button>
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<UserPlus className="size-4 text-muted-foreground" />
<span>Add another account</span>
@@ -308,6 +314,7 @@ export function LeftSidebar() {
)}
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</aside>
);
}
+25 -14
View File
@@ -1,59 +1,70 @@
import { Link } from 'react-router-dom';
import { Info, BookOpen, Shield, Code, ScrollText } from 'lucide-react';
interface LinkFooterProps {
/** Optional callback fired when an internal (React Router) link is clicked. */
onNavigate?: () => void;
}
const chipClass =
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors';
const iconClass = 'size-3 shrink-0';
/** Shared footer links used in both sidebars. */
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">
<footer className="mt-auto pt-3 pb-3 -mx-1 sidebar:bg-background/85 sidebar:rounded-xl sidebar:p-3">
<nav className="flex items-center justify-center gap-0.5 flex-wrap" aria-label="Footer links">
<a
href="https://about.ditto.pub"
className="text-primary hover:underline"
className={chipClass}
target="_blank"
rel="noopener noreferrer"
>
<Info className={iconClass} />
About
</a>
{' · '}
<a
href="https://about.ditto.pub/docs/"
className="text-primary hover:underline"
className={chipClass}
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className={iconClass} />
Docs
</a>
{' · '}
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
<Link to="/privacy" className={chipClass} onClick={onNavigate}>
<Shield className={iconClass} />
Privacy
</Link>
{' · '}
<a
href="https://gitlab.com/soapbox-pub/ditto"
className="text-primary hover:underline"
className={chipClass}
target="_blank"
rel="noopener noreferrer"
>
<Code className={iconClass} />
Source
</a>
{' · '}
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
<Link to="/changelog" className={chipClass} onClick={onNavigate}>
<ScrollText className={iconClass} />
Changelog
</Link>
{' · '}
<a
href="https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git"
className="text-primary hover:underline"
className={chipClass}
target="_blank"
rel="noopener noreferrer"
>
<span className="text-xs leading-none" aria-hidden>🎭</span>
Edit with Shakespeare
</a>
</p>
</nav>
</footer>
);
}
+10 -4
View File
@@ -1,4 +1,4 @@
import { Suspense, useState, useMemo, useCallback } from 'react';
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { RightSidebar } from '@/components/RightSidebar';
@@ -8,7 +8,7 @@ import { MobileBottomNav } from '@/components/MobileBottomNav';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { CursorFireEffect } from '@/components/CursorFireEffect';
import { Skeleton } from '@/components/ui/skeleton';
import { DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
@@ -106,10 +106,12 @@ function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
const { config } = useAppContext();
const { hidden: navHidden } = useScrollDirection(scrollContainer);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={navHidden}>
{/* Magic Mouse fire particle overlay */}
@@ -136,7 +138,10 @@ function MainLayoutInner() {
being hidden. This depends on MobileTopBar having a transparent /
semi-transparent background — a solid top bar would obscure the
content underneath. Only active below the sidebar breakpoint. */}
<div className={cn("relative flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}>
<div
ref={(el) => { centerColumnRef.current = el; setCenterColumnEl(el); }}
className={cn("relative z-0 flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}
>
<Outlet />
{/* Desktop FAB — sticky within the feed column so it stays
@@ -175,6 +180,7 @@ function MainLayoutInner() {
)}
</NavHiddenContext.Provider>
</DrawerContext.Provider>
</CenterColumnContext.Provider>
);
}
+13 -3
View File
@@ -1,6 +1,6 @@
import { useState, useId, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react';
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
@@ -11,6 +11,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { LinkFooter } from '@/components/LinkFooter';
import { EmojifiedText } from '@/components/CustomEmoji';
import LoginDialog from '@/components/auth/LoginDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
@@ -60,6 +61,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [accountExpanded, setAccountExpanded] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const { startSignup } = useOnboarding();
const { theme, customTheme, themes } = useTheme();
@@ -269,6 +271,13 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</div>
</button>
))}
<button
onClick={() => { handleClose(); setFollowQROpen(true); }}
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
>
<QrCode className="size-5 shrink-0" />
<span>Share profile</span>
</button>
<button
onClick={() => { handleClose(); setLoginDialogOpen(true); }}
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
@@ -318,7 +327,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</div>
</nav>
<div className="px-2">
<div className="px-2 safe-area-bottom">
<LinkFooter onNavigate={handleClose} />
</div>
</div>
@@ -362,7 +371,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
</div>
</nav>
<div className="px-2">
<div className="px-2 safe-area-bottom">
<LinkFooter onNavigate={handleClose} />
</div>
</div>
@@ -376,6 +385,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
onLogin={() => setLoginDialogOpen(false)}
onSignupClick={startSignup}
/>
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</>
);
}
+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]));
}
+12 -9
View File
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useAppContext } from "@/hooks/useAppContext";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
import { useEncryptedSettings, setLocalSettingsSync } from "@/hooks/useEncryptedSettings";
import { isSyncDone } from "@/hooks/useInitialSync";
import { parseBlossomServerList } from "@/lib/appBlossom";
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
@@ -246,16 +246,13 @@ export function NostrSync() {
// Get the remote sync timestamp
const remoteSync = encryptedSettings.lastSync || 0;
// On first load, seed the ref with the current remote timestamp so that
// subsequent effect firings (e.g. after window focus) don't re-apply the
// same snapshot. We still apply this snapshot below so that settings
// restored from the query cache (seeded by useInitialSync) take effect
// immediately on page reload without waiting for the 5-second delay.
// On first load, mark seeded so this block only runs once.
// We intentionally do NOT pre-set lastSyncedTimestamp here — leaving it
// at 0 lets the `remoteSync <= lastSyncedTimestamp` guard below fall
// through so the settings are actually applied on this first pass.
// Line 277 then records the timestamp to prevent re-application.
if (!seededTimestamp) {
lastSyncedTimestamp.current = remoteSync;
setSeededTimestamp(true);
// Fall through — apply the settings this time so that sidebarOrder
// and other fields are always applied on the first load.
}
// Don't overwrite local config if we just saved settings (short-circuit for
@@ -434,6 +431,12 @@ export function NostrSync() {
);
}
}
// Persist the sync timestamp so the next page load can render immediately
// from localStorage without showing the spinner.
if (user && remoteSync > 0) {
setLocalSettingsSync(user.pubkey, remoteSync);
}
}, [
user,
encryptedSettings,
+241 -382
View File
@@ -21,7 +21,7 @@ import {
Zap,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
@@ -74,6 +74,7 @@ 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";
@@ -97,7 +98,8 @@ import { genUserName } from "@/lib/genUserName";
import { getDisplayName } from "@/lib/getDisplayName";
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents";
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
import { isSingleImagePost } from "@/lib/noteContent";
import { shareOrCopy } from "@/lib/share";
import { timeAgo } from "@/lib/timeAgo";
@@ -117,6 +119,113 @@ function ProfileCardContent({ event }: { event: NostrEvent }) {
);
}
/* ──── Shared activity card shell for reaction / repost / zap / poll vote ──── */
interface ActivityCardProps {
/** The round element in the left column (icon bubble or avatar). */
icon: ReactNode;
/** The actor row content (avatar + name + label + timestamp). */
actorRow: ReactNode;
/** Optional extra content below the actor row (zap message, vote label, etc.). */
children?: ReactNode;
/** Threaded mode: connector line below icon, no bottom border. */
threaded?: boolean;
/** Last item in thread — no connector line, has bottom border. */
threadedLast?: boolean;
/** Custom connector line class. */
threadedLineClassName?: string;
className?: string;
onClick?: React.MouseEventHandler;
onAuxClick?: React.MouseEventHandler;
}
export function ActivityCard({
icon,
actorRow,
children,
threaded,
threadedLast,
threadedLineClassName,
className,
onClick,
onAuxClick,
}: ActivityCardProps) {
const isThreaded = threaded || threadedLast;
return (
<article
className={cn(
"px-4 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
isThreaded
? cn("pt-3", threaded ? "pb-0" : "pb-3 border-b border-border")
: "py-3 border-b border-border",
className,
)}
onClick={onClick}
onAuxClick={onAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{icon}
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div className={cn("flex-1 min-w-0", isThreaded ? "min-h-10 flex flex-col justify-center" : "", threaded && "pb-3")}>
{actorRow}
{children}
</div>
</div>
</article>
);
}
/** Reusable actor row: small avatar + display name + action label + timestamp. */
export interface ActorRowProps {
pubkey: string;
profileUrl: string;
avatarShape: Parameters<typeof Avatar>[0]['shape'];
picture?: string;
displayName: string;
authorEvent?: NostrEvent;
isLoading?: boolean;
label: string;
/** Extra inline elements after the label (e.g. zap amount). */
extra?: ReactNode;
/** Formatted timestamp string (e.g. timeAgo or full date). */
timestampLabel: string;
}
export function ActorRow({ pubkey, profileUrl, avatarShape, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-20" />
</div>
);
}
return (
<div className="flex items-center gap-2">
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{authorEvent ? <EmojifiedText tags={authorEvent.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
{extra}
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timestampLabel}</span>
</div>
);
}
interface NoteCardProps {
event: NostrEvent;
className?: string;
@@ -218,6 +327,8 @@ export const NoteCard = memo(function NoteCard({
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
const pollVoteLabel = usePollVoteLabel(event);
const metadata = author.data?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = getDisplayName(metadata, event.pubkey);
@@ -248,7 +359,9 @@ export const NoteCard = memo(function NoteCard({
target.closest("[data-radix-dialog-content]") ||
target.closest("[data-vaul-drawer]") ||
target.closest("[data-vaul-drawer-overlay]") ||
target.closest('[data-testid="zap-modal"]')
target.closest('[data-testid="zap-modal"]') ||
target.closest("button") ||
target.closest("a")
) {
return;
}
@@ -263,7 +376,9 @@ export const NoteCard = memo(function NoteCard({
target.closest("[data-radix-dialog-content]") ||
target.closest("[data-vaul-drawer]") ||
target.closest("[data-vaul-drawer-overlay]") ||
target.closest('[data-testid="zap-modal"]')
target.closest('[data-testid="zap-modal"]') ||
target.closest("button") ||
target.closest("a")
) {
return;
}
@@ -290,6 +405,7 @@ export const NoteCard = memo(function NoteCard({
const isProfileBadges = event.kind === 10008 || event.kind === 30008;
const isBadge = isBadgeDefinition || isProfileBadges;
const isReaction = event.kind === 7;
const isPollVote = event.kind === 1018;
const isRepost = event.kind === 6 || event.kind === 16;
const isPhoto = event.kind === 20;
const isNormalVideo = event.kind === 21;
@@ -307,6 +423,8 @@ 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;
@@ -332,12 +450,15 @@ export const NoteCard = memo(function NoteCard({
!isEmojiPack &&
!isBadge &&
!isReaction &&
!isPollVote &&
!isRepost &&
!isPhoto &&
!isVideo &&
!isAudioKind &&
!isDevKind &&
!isZapstoreApp &&
!isZapstoreRelease &&
!isZapstoreAsset &&
!isAppHandler &&
!isEncryptedDM &&
!isLetter &&
@@ -415,11 +536,12 @@ export const NoteCard = memo(function NoteCard({
return [parentAuthor];
}, [event.tags, isTextNote, isReply, event.pubkey]);
// Extract the parent event ID for reply hover card preview
const parentEventId = useMemo(() => {
// Extract the parent event ID + relay/author hints for reply hover card preview
const parentHints = useMemo(() => {
if (!isReply) return undefined;
return getParentEventId(event);
return getParentEventHints(event);
}, [event, isReply]);
const parentEventId = parentHints?.id;
// Kind 34236 specific
const imeta = useMemo(
@@ -461,7 +583,12 @@ export const NoteCard = memo(function NoteCard({
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
{isComment && <CommentContext event={event} />}
{isReply && (
<ReplyContext pubkeys={replyToPubkeys} parentEventId={parentEventId} />
<ReplyContext
pubkeys={replyToPubkeys}
parentEventId={parentEventId}
parentRelayHint={parentHints?.relayHint}
parentAuthorHint={parentHints?.authorHint}
/>
)}
{/* Content — kind-based dispatch, guarded by NIP-36 content-warning */}
@@ -534,7 +661,23 @@ export const NoteCard = memo(function NoteCard({
) : isNsite ? (
<NsiteCard event={event} />
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} compact />
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAppContent event={event} compact />
</div>
</div>
) : isZapstoreRelease ? (
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreReleaseContent event={event} compact />
</div>
</div>
) : isZapstoreAsset ? (
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAssetContent event={event} compact />
</div>
</div>
) : isAppHandler ? (
<AppHandlerContent event={event} compact />
) : isEncryptedDM ? (
@@ -777,399 +920,107 @@ export const NoteCard = memo(function NoteCard({
);
}
// ── Reaction layout (kind 7) — compact activity-style card ──
// ── Reaction layout (kind 7) ──
if (isReaction) {
// Threaded reaction (used in AncestorThread with connector line)
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<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 text-lg leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="h-5 w-5 object-contain"
/>
</div>
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div
className={cn(
"flex-1 min-w-0 flex items-center min-h-10",
threaded && "pb-3",
)}
>
<div className="flex items-center gap-2">
{author.isLoading ? (
<Skeleton className="size-6 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{author.isLoading ? (
<Skeleton className="h-3.5 w-20" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground">reacted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</article>
);
}
// Normal reaction card (standalone or in feed)
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<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 text-xl leading-none">
<ReactionEmoji
content={event.content}
tags={event.tags}
className="h-6 w-6 object-contain"
/>
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-pink-500/10 shrink-0 text-lg leading-none", iconSize)}>
<ReactionEmoji content={event.content} tags={event.tags} className="h-5 w-5 object-contain" />
</div>
{/* Author + "reacted" label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reacted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</>
)}
</div>
</div>
</article>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
/>
);
}
// ── Repost layout (kind 6 / 16) — compact activity-style card ──
// ── Repost layout (kind 6 / 16) ──
if (isRepost) {
// Threaded repost (used in AncestorThread with connector line)
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
{/* Repost icon bubble instead of avatar */}
<div className="flex items-center justify-center size-10 rounded-full bg-accent/10 shrink-0">
<RepostIcon className="size-5 text-accent" />
</div>
{threaded && (
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
)}
</div>
<div
className={cn(
"flex-1 min-w-0 flex items-center min-h-10",
threaded && "pb-3",
)}
>
<div className="flex items-center gap-2">
{author.isLoading ? (
<Skeleton className="size-6 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage
src={metadata?.picture}
alt={displayName}
/>
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{author.isLoading ? (
<Skeleton className="h-3.5 w-20" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</div>
</div>
</div>
</article>
);
}
// Normal repost card (standalone or in feed)
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
{/* Repost icon */}
<div className="flex items-center justify-center size-11 rounded-full bg-accent/10 shrink-0">
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-accent/10 shrink-0", iconSize)}>
<RepostIcon className="size-5 text-accent" />
</div>
{/* Author + "reposted" label */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{author.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="shrink-0"
onClick={(e) => e.stopPropagation()}
>
<Avatar shape={avatarShape} className="size-6">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-semibold text-sm hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>
{displayName}
</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground">reposted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{timeAgo(event.created_at)}
</span>
</>
)}
</div>
</div>
</article>
}
actorRow={
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
/>
);
}
// ── Zap receipt layout (kind 9735) — mirrors reaction layout exactly ──
// ── Zap receipt layout (kind 9735) ──
if (isZap) {
const zapAmountSats = Math.floor(extractZapAmount(event) / 1000);
const zapMessage = extractZapMessage(event);
const zapActorRow = (
<div className="flex items-center gap-2">
{zapSender.isLoading ? (
<>
<Skeleton className="size-6 rounded-full shrink-0" />
<Skeleton className="h-3.5 w-20" />
</>
) : (
<>
{zapSenderPubkey && (
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
<Link to={zapSenderUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={zapSenderShape} className="size-6">
<AvatarImage src={zapSenderMeta?.picture} alt={zapSenderName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{zapSenderName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{zapSenderPubkey && (
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
<Link to={zapSenderUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{zapSender.data?.event ? <EmojifiedText tags={zapSender.data.event.tags}>{zapSenderName}</EmojifiedText> : zapSenderName}
</Link>
</ProfileHoverCard>
)}
<span className="text-sm text-muted-foreground shrink-0">zapped</span>
{zapAmountSats > 0 && (
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
}
actorRow={
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} avatarShape={zapSenderShape} picture={zapSenderMeta?.picture}
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
extra={zapAmountSats > 0 ? (
<span className="text-sm font-semibold text-amber-500 shrink-0">
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
</span>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</>
)}
</div>
);
if (threaded || threadedLast) {
return (
<article
className={cn(
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
threaded ? "pb-0" : "pb-3 border-b border-border",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="flex items-center justify-center size-10 rounded-full bg-amber-500/10 shrink-0">
<Zap className="size-5 text-amber-500 fill-amber-500" />
</div>
{threaded && <div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />}
</div>
<div className={cn("flex-1 min-w-0 flex flex-col justify-center min-h-10", threaded && "pb-3")}>
{zapActorRow}
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</div>
</div>
</article>
);
}
return (
<article
className={cn(
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
) : undefined}
/>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-11 rounded-full bg-amber-500/10 shrink-0">
<Zap className="size-5 text-amber-500 fill-amber-500" />
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</ActivityCard>
);
}
// ── Poll vote layout (kind 1018) ──
if (isPollVote) {
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
return (
<ActivityCard
icon={
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className={iconSize}>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
}
actorRow={
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
</Link>
</ProfileHoverCard>
<span className="text-sm text-muted-foreground shrink-0">voted</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
</div>
<div className="flex-1 min-w-0 flex flex-col">
{zapActorRow}
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">&ldquo;{zapMessage}&rdquo;</p>}
</div>
</div>
</article>
}
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
>
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
</ActivityCard>
);
}
@@ -2005,7 +1856,15 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
32267: {
icon: Package,
action: "published an app",
action: "published a Zapstore app",
},
30063: {
icon: Package,
action: "published a Zapstore release",
},
3063: {
icon: Package,
action: "published a Zapstore asset",
},
31990: {
icon: Package,
+103 -83
View File
@@ -1,37 +1,22 @@
import type { NostrEvent } from "@nostrify/nostrify";
import { ExternalLink, FileText, Globe, Server } from "lucide-react";
import { nip19 } from "nostr-tools";
import { ExternalLink, FileText, Globe, Play, Server } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ExternalFavicon } from "@/components/ExternalFavicon";
import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { useLinkPreview } from "@/hooks/useLinkPreview";
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
import { cn } from "@/lib/utils";
interface NsiteCardProps {
event: NostrEvent;
}
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
function hexToBase36(hex: string): string {
let n = 0n;
for (let i = 0; i < hex.length; i++) {
n = n * 16n + BigInt(parseInt(hex[i], 16));
}
const b36 = n.toString(36);
return b36.padStart(50, "0");
}
/** Build the nsite.lol gateway URL for an nsite event. */
function getNsiteUrl(event: NostrEvent): string {
const dTag = event.tags.find(([n]) => n === "d")?.[1];
if (event.kind === 35128 && dTag) {
const pubkeyB36 = hexToBase36(event.pubkey);
return `https://${pubkeyB36}${dTag}.nsite.lol`;
}
const npub = nip19.npubEncode(event.pubkey);
return `https://${npub}.nsite.lol`;
return `https://${getNsiteSubdomain(event)}.nsite.lol`;
}
/** Renders an nsite deployment card with a rich link preview. */
@@ -51,90 +36,125 @@ export function NsiteCard({ event }: NsiteCardProps) {
const image = preview?.thumbnail_url;
const previewTitle = preview?.title;
const [previewOpen, setPreviewOpen] = useState(false);
if (isLoading) {
return <NsiteCardSkeleton />;
}
return (
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
<>
<div
className={cn(
"group block mt-2 rounded-2xl border border-border overflow-hidden",
"group mt-2 rounded-2xl border border-border overflow-hidden",
"hover:bg-secondary/40 transition-colors",
)}
onClick={(e) => e.stopPropagation()}
>
{/* Link preview thumbnail */}
{image && (
<div className="w-full overflow-hidden bg-muted">
<img
src={image}
alt=""
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
}}
/>
</div>
)}
<div className="px-3.5 py-2.5 space-y-1.5">
{/* Title with favicon */}
<div className="flex items-center gap-2 min-w-0">
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
<p className="text-sm font-semibold leading-snug line-clamp-2">
{previewTitle || displayName}
</p>
</div>
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
{(description || preview?.author_name) && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{description || preview?.author_name}
</p>
{/* Link preview thumbnail — clicking navigates to the site */}
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className="block"
onClick={(e) => e.stopPropagation()}
>
{image && (
<div className="w-full overflow-hidden bg-muted">
<img
src={image}
alt=""
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
}}
/>
</div>
)}
{/* Deployment stats + source link */}
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
{pathTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="size-3" />
{pathTags.length} {pathTags.length === 1 ? "file" : "files"}
</span>
<div className="px-3.5 pt-2.5 pb-1.5 space-y-1.5">
{/* Title with favicon */}
<div className="flex items-center gap-2 min-w-0">
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
<p className="text-sm font-semibold leading-snug line-clamp-2">
{previewTitle || displayName}
</p>
</div>
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
{(description || preview?.author_name) && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{description || preview?.author_name}
</p>
)}
{serverTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<Server className="size-3" />
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
</span>
{/* Deployment stats */}
{(pathTags.length > 0 || serverTags.length > 0) && (
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
{pathTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="size-3" />
{pathTags.length} {pathTags.length === 1 ? "file" : "files"}
</span>
)}
{serverTags.length > 0 && (
<span className="inline-flex items-center gap-1">
<Server className="size-3" />
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
</span>
)}
</div>
)}
{sourceUrl && (
</div>
</a>
{/* Action row */}
<div className="px-3.5 pb-2.5 flex items-center gap-2">
<Button
size="sm"
className="h-7 text-xs"
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
>
<Play className="size-3 mr-1" />
Run
</Button>
{sourceUrl ? (
<Button asChild size="sm" variant="secondary" className="h-7 text-xs">
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
"hover:bg-primary/10 hover:text-primary transition-colors",
)}
onClick={(e) => e.stopPropagation()}
>
<Globe className="size-3" />
<span>Source</span>
<Globe className="size-3 mr-1" />
Source
</a>
)}
{!sourceUrl && (
<span className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-primary/10 hover:text-primary transition-colors">
<ExternalLink className="size-3" />
<span>Visit</span>
</span>
)}
</div>
</Button>
) : (
<Button asChild size="sm" variant="secondary" className="h-7 text-xs">
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3 mr-1" />
Visit
</a>
</Button>
)}
</div>
</a>
</div>
<NsitePreviewDialog
event={event}
appName={previewTitle || displayName || "nsite"}
appPicture={undefined}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
</>
);
}
+389
View File
@@ -0,0 +1,389 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Package, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCenterColumn } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
interface Rect { left: number; top: number; width: number; height: number }
/** Track the viewport-relative bounding rect of an element, updating on resize. */
function useElementRect(el: HTMLElement | null): Rect | null {
const [rect, setRect] = useState<Rect | null>(null);
useEffect(() => {
if (!el) { setRect(null); return; }
const measure = () => {
const r = el.getBoundingClientRect();
setRect({ left: r.left, top: r.top, width: r.width, height: r.height });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, [el]);
return rect;
}
/** The wildcard-to-localhost preview domain used by Shakespeare's iframe-fetch-client. */
const PREVIEW_DOMAIN = 'local-shakespeare.dev';
interface JSONRPCFetchRequest {
jsonrpc: '2.0';
method: 'fetch';
params: {
request: {
url: string;
method: string;
headers: Record<string, string>;
body: string | null;
};
};
id: number;
}
interface JSONRPCResponse {
jsonrpc: '2.0';
result?: {
status: number;
statusText: string;
headers: Record<string, string>;
body: string | null;
};
error?: {
code: number;
message: string;
};
id: number;
}
/**
* Build the path→sha256 manifest from a nsite event's `path` tags.
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
*/
function buildManifest(event: NostrEvent): Map<string, string> {
const manifest = new Map<string, string>();
for (const tag of event.tags) {
if (tag[0] === 'path' && tag[1] && tag[2]) {
manifest.set(tag[1], tag[2]);
}
}
return manifest;
}
/**
* Resolve the Blossom servers for a nsite event.
* Prefers the `server` tags on the event; falls back to the provided app servers.
*/
function resolveServers(event: NostrEvent, appServers: string[]): string[] {
const eventServers = event.tags
.filter(([name]) => name === 'server')
.map(([, url]) => url)
.filter((url) => {
try { new URL(url); return true; } catch { return false; }
});
return eventServers.length > 0 ? eventServers : appServers;
}
/**
* Fetch a blob from the given sha256 by trying each Blossom server in order.
* Returns a Response from the first server that responds successfully, or
* throws if all servers fail.
*/
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
let lastError: unknown;
for (const server of servers) {
const base = server.replace(/\/+$/, '');
const url = `${base}/${sha256}`;
try {
const res = await fetch(url);
if (res.ok) return res;
} catch (err) {
lastError = err;
}
}
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
}
/**
* Guess a MIME type from a file path extension.
* Falls back to 'application/octet-stream' for unknown extensions.
*/
function guessMimeType(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
html: 'text/html',
htm: 'text/html',
css: 'text/css',
js: 'application/javascript',
mjs: 'application/javascript',
json: 'application/json',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
wav: 'audio/wav',
wasm: 'application/wasm',
xml: 'application/xml',
txt: 'text/plain',
md: 'text/markdown',
};
return map[ext] ?? 'application/octet-stream';
}
interface NsitePreviewDialogProps {
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
event: NostrEvent;
/** Display name for the app. */
appName: string;
/** Optional app icon URL. */
appPicture?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* An in-app preview panel that covers the center column and loads an nsite in
* a sandboxed iframe, using the Shakespeare iframe-fetch-client protocol over
* local-shakespeare.dev.
*
* Instead of proxying requests through an nsite gateway, this component serves
* files directly from Blossom servers using the manifest data embedded in the
* nsite event's `path` tags. Each path tag maps a file path to its sha256 hash,
* which is used to construct a Blossom content-addressed URL.
*
* The panel is portaled into the center column DOM element (via CenterColumnContext)
* and uses `position: fixed` to fill the viewport column area.
*
* The parent window intercepts JSON-RPC `fetch` requests from the iframe and
* serves them directly from Blossom, so the SPA can run without any gateway dependency.
*/
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const centerColumn = useCenterColumn();
const columnRect = useElementRect(open ? centerColumn : null);
const { config } = useAppContext();
// Derive the iframe origin from the NIP-5A canonical subdomain for this event
const subdomain = getNsiteSubdomain(event);
const iframeOrigin = `https://${subdomain}.${PREVIEW_DOMAIN}`;
const iframeSrc = `${iframeOrigin}/`;
// Build the manifest and server list from the event (memoised per event identity)
const manifest = useRef<Map<string, string>>(new Map());
const servers = useRef<string[]>([]);
useEffect(() => {
manifest.current = buildManifest(event);
const appServers = getEffectiveBlossomServers(
config.blossomServerMetadata,
config.useAppBlossomServers ?? true,
);
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
/** Send a JSON-RPC response back to the iframe. */
const sendResponse = useCallback((message: JSONRPCResponse) => {
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
}, [iframeOrigin]);
/** Handle a fetch request from the iframe by serving files directly from Blossom. */
const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => {
const { params, id } = request;
const { request: fetchRequest } = params;
try {
const requestedUrl = new URL(fetchRequest.url);
// Only serve requests for our iframe origin
if (requestedUrl.origin !== iframeOrigin) {
sendResponse({
jsonrpc: '2.0',
error: { code: -32003, message: 'Origin mismatch' },
id,
});
return;
}
// Strip query string from path for manifest lookup
const requestedPath = requestedUrl.pathname;
// Look up the sha256 for this path in the manifest.
// If not found, fall back to /index.html (SPA client-side routing).
let sha256 = manifest.current.get(requestedPath);
let servingPath = requestedPath;
if (!sha256) {
sha256 = manifest.current.get('/index.html');
servingPath = '/index.html';
}
if (!sha256) {
sendResponse({
jsonrpc: '2.0',
result: {
status: 404,
statusText: 'Not Found',
headers: { 'Content-Type': 'text/plain' },
body: btoa('Not Found'),
},
id,
});
return;
}
// Fetch the blob from Blossom, trying each server in order
const res = await fetchFromBlossom(sha256, servers.current);
// Read as ArrayBuffer → base64 so binary assets work correctly
const buffer = await res.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const bodyBase64 = btoa(binary);
// Always determine content type from the file extension.
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
// files), which causes browsers to reject module scripts. The file path from
// the manifest is authoritative for the correct MIME type.
const contentType = guessMimeType(servingPath);
// The iframe-fetch-client (main.js) checks headers with Title-Case keys
// (e.g. "Content-Type"), and does an exact equality check against "text/html"
// for routing decisions.
const responseHeaders: Record<string, string> = {
'Content-Type': contentType,
'Content-Length': String(bytes.byteLength),
};
sendResponse({
jsonrpc: '2.0',
result: {
status: 200,
statusText: 'OK',
headers: responseHeaders,
body: bodyBase64,
},
id,
});
} catch (err) {
sendResponse({
jsonrpc: '2.0',
error: { code: -32002, message: String(err) },
id,
});
}
}, [iframeOrigin, sendResponse]);
/** Handle navigation state updates from the iframe (no-op). */
const handleNavigationState = useCallback((_params: {
currentUrl: string;
canGoBack: boolean;
canGoForward: boolean;
}) => {
// intentionally empty
}, []);
// Listen for messages from the iframe
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== iframeOrigin) return;
const message = event.data;
if (message?.jsonrpc !== '2.0') return;
if (message.method === 'fetch') {
handleFetch(message as JSONRPCFetchRequest);
} else if (message.method === 'updateNavigationState') {
handleNavigationState(message.params);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [iframeOrigin, handleFetch, handleNavigationState]);
if (!open || !centerColumn || !columnRect) return null;
// If the user has scrolled down, columnRect.top is negative (the column top
// is above the viewport). Clamp to 0 so the panel always starts at the
// viewport top edge and never grows taller than the viewport.
const panelTop = Math.max(0, columnRect.top);
const panelHeight = window.innerHeight - panelTop;
return createPortal(
<div
className="fixed z-50 flex flex-col bg-background"
style={{
left: columnRect.left,
top: panelTop,
width: columnRect.width,
height: panelHeight,
}}
>
{/* Nav bar */}
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{appPicture ? (
<img
src={appPicture}
alt={appName}
className="size-6 rounded-md object-cover shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Package className="size-3.5 text-primary/50" />
</div>
)}
<span className="text-sm font-medium truncate">{appName}</span>
</div>
{/* Close */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => onOpenChange(false)}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
{/* iframe */}
<div className="flex-1 min-h-0 bg-background">
<iframe
key={`${subdomain}-${open}`}
ref={iframeRef}
src={iframeSrc}
className="w-full h-full border-0"
title={`${appName} preview`}
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
</div>,
document.body,
);
}
+362 -22
View File
@@ -1,10 +1,22 @@
import { useState, useMemo } from 'react';
import { BarChart3, CheckCircle2, Clock } from 'lucide-react';
import { Link } from 'react-router-dom';
import { BarChart3, CheckCircle2, Clock, X, ChevronRight } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { NoteContent } from '@/components/NoteContent';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EmojifiedText } from '@/components/CustomEmoji';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { getAvatarShape } from '@/lib/avatarShape';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -61,6 +73,61 @@ function tallyVotes(
return counts;
}
/** Get voter events for a specific option ID. */
function getVotersForOption(
votes: NostrEvent[],
optionId: string,
pollType: string,
): NostrEvent[] {
return votes.filter((vote) => {
const responseTags = vote.tags.filter(([n]) => n === 'response');
if (pollType === 'singlechoice') {
return responseTags[0]?.[1] === optionId;
} else {
return responseTags.some(([, id]) => id === optionId);
}
});
}
/** Clickable avatar stack + "N votes" label. */
function VoterAvatarsButton({
votes,
totalVotes,
authorsMap,
onClick,
className,
}: {
votes: NostrEvent[];
totalVotes: number;
authorsMap?: Map<string, { pubkey: string; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
onClick: () => void;
className?: string;
}) {
return (
<button onClick={onClick} className={cn('flex items-center gap-1.5 group', className)}>
<div className="flex -space-x-1.5">
{votes.slice(0, 6).map((vote) => {
const authorData = authorsMap?.get(vote.pubkey);
const metadata = authorData?.metadata;
const avatarShape = getAvatarShape(metadata);
const name = metadata?.name || genUserName(vote.pubkey);
return (
<Avatar key={vote.pubkey} shape={avatarShape} className="size-5 ring-1 ring-background">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
</div>
<span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
</span>
</button>
);
}
export function PollContent({ event }: { event: NostrEvent }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -72,6 +139,10 @@ export function PollContent({ event }: { event: NostrEvent }) {
const endsAt = getTag(event.tags, 'endsAt');
const isExpired = endsAt ? Number(endsAt) < Math.floor(Date.now() / 1000) : false;
// Modal state
const [votersModalOpen, setVotersModalOpen] = useState(false);
const [votersModalOptionId, setVotersModalOptionId] = useState<string | null>(null);
// Fetch vote events
const { data: votes } = useQuery<NostrEvent[]>({
queryKey: ['poll-votes', event.id],
@@ -126,6 +197,19 @@ export function PollContent({ event }: { event: NostrEvent }) {
});
};
// Collect all voter pubkeys for batch profile fetching
const allVoterPubkeys = useMemo(() => {
if (!votes) return [];
return votes.map((v) => v.pubkey);
}, [votes]);
const { data: authorsMap } = useAuthors(allVoterPubkeys);
const openVotersModal = (optionId: string | null) => {
setVotersModalOptionId(optionId);
setVotersModalOpen(true);
};
return (
<div className="mt-2" onClick={(e) => e.stopPropagation()}>
{/* Question */}
@@ -133,7 +217,7 @@ export function PollContent({ event }: { event: NostrEvent }) {
<NoteContent event={event} />
</div>
{/* Poll type + expiry badges */}
{/* Poll type + expiry badges + voter avatars + vote count */}
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground bg-secondary/60 px-2 py-0.5 rounded-full">
<BarChart3 className="size-3" />
@@ -145,6 +229,17 @@ export function PollContent({ event }: { event: NostrEvent }) {
Ended
</span>
)}
{/* Voter avatars + count pushed to the right */}
{showResults && totalVotes > 0 && (
<VoterAvatarsButton
votes={votes ?? []}
totalVotes={totalVotes}
authorsMap={authorsMap}
onClick={() => openVotersModal(null)}
className="ml-auto"
/>
)}
</div>
{/* Options */}
@@ -192,26 +287,271 @@ export function PollContent({ event }: { event: NostrEvent }) {
})}
</div>
{/* Vote button or total */}
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-muted-foreground">
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
</span>
{!showResults && user && (
<button
onClick={handleVote}
disabled={!selectedOption}
className={cn(
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
selectedOption
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-muted-foreground cursor-not-allowed',
)}
>
Vote
</button>
)}
</div>
{/* Vote button + voter avatars (voting mode only) */}
{!showResults && (
<div className="flex items-center justify-between mt-3">
{totalVotes > 0 ? (
<VoterAvatarsButton
votes={votes ?? []}
totalVotes={totalVotes}
authorsMap={authorsMap}
onClick={() => openVotersModal(null)}
/>
) : (
<span className="text-xs text-muted-foreground">0 votes</span>
)}
{user && (
<button
onClick={handleVote}
disabled={!selectedOption}
className={cn(
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
selectedOption
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-muted-foreground cursor-not-allowed',
)}
>
Vote
</button>
)}
</div>
)}
{/* Voters Modal */}
<PollVotersModal
open={votersModalOpen}
onOpenChange={setVotersModalOpen}
allVotes={votes ?? []}
options={options}
pollType={pollType}
initialOptionId={votersModalOptionId}
authorsMap={authorsMap}
/>
</div>
);
}
/* ──── Poll Voters Modal ──── */
interface PollVotersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
allVotes: NostrEvent[];
options: PollOption[];
pollType: string;
initialOptionId?: string | null;
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
}
function PollVotersModal({ open, onOpenChange, allVotes, options, pollType, initialOptionId, authorsMap }: PollVotersModalProps) {
const [activeFilter, setActiveFilter] = useState<string | null>(initialOptionId ?? null);
// Sync filter when modal opens with a specific option
useMemo(() => {
if (open) setActiveFilter(initialOptionId ?? null);
}, [open, initialOptionId]);
// Build a map from option ID to label for display
const optionLabelMap = useMemo(() => {
const map = new Map<string, string>();
for (const opt of options) {
map.set(opt.id, opt.label);
}
return map;
}, [options]);
// Filter voters based on active filter
const filteredVoters = useMemo(() => {
if (activeFilter === null) return allVotes;
return getVotersForOption(allVotes, activeFilter, pollType);
}, [allVotes, activeFilter, pollType]);
// Tally per option for the count badges
const tally = useMemo(() => tallyVotes(allVotes, pollType), [allVotes, pollType]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[460px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">Voters</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Option filter bars — scrollable when more than 3 */}
<ScrollArea className={cn('px-4', options.length > 2 && 'max-h-[120px]')}>
<div className="space-y-1.5">
{/* "All" bar */}
<button
onClick={() => setActiveFilter(null)}
className={cn(
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
activeFilter === null ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
)}
>
<div
className={cn(
'absolute inset-0 transition-all duration-500',
activeFilter === null ? 'bg-primary/15' : 'bg-secondary/40',
)}
style={{ width: '100%' }}
/>
<div className="relative flex items-center justify-between px-3 py-2">
<span className={cn('text-sm', activeFilter === null && 'font-semibold')}>All</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
{allVotes.length}
</span>
</div>
</button>
{/* Per-option bars */}
{options.map((opt) => {
const count = tally.get(opt.id) ?? 0;
const pct = allVotes.length > 0 ? Math.round((count / allVotes.length) * 100) : 0;
const isActive = activeFilter === opt.id;
return (
<button
key={opt.id}
onClick={() => setActiveFilter(opt.id)}
className={cn(
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
isActive ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
)}
>
<div
className={cn(
'absolute inset-0 transition-all duration-500',
isActive ? 'bg-primary/15' : 'bg-secondary/40',
)}
style={{ width: `${pct}%` }}
/>
<div className="relative flex items-center justify-between px-3 py-2">
<span className={cn('text-sm break-words min-w-0', isActive && 'font-semibold')}>{opt.label}</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
{count}
</span>
</div>
</button>
);
})}
</div>
</ScrollArea>
{/* Primary accent divider — only when scrollbox is active */}
{options.length > 2 && <div className="mx-4 h-1 bg-primary rounded-full" />}
{/* Voter list */}
<ScrollArea className="max-h-[60vh]">
{filteredVoters.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm">
No votes yet
</div>
) : (
<div className="divide-y divide-border">
{filteredVoters.map((vote) => (
<VoterRow
key={vote.id}
vote={vote}
optionLabelMap={optionLabelMap}
pollType={pollType}
authorsMap={authorsMap}
/>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
/* ──── Voter Row ──── */
interface VoterRowProps {
vote: NostrEvent;
optionLabelMap: Map<string, string>;
pollType: string;
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
}
function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps) {
// Use batch-fetched author data if available, fall back to individual fetch
const individualAuthor = useAuthor(authorsMap?.has(vote.pubkey) ? undefined : vote.pubkey);
const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data;
const metadata = authorData?.metadata;
const avatarShape = getAvatarShape(metadata);
const displayName = metadata?.name || genUserName(vote.pubkey);
const nevent = useMemo(
() => nip19.neventEncode({ id: vote.id, author: vote.pubkey }),
[vote.id, vote.pubkey],
);
// Resolve which option(s) this person voted for
const votedOptions = useMemo(() => {
const responseTags = vote.tags.filter(([n]) => n === 'response');
if (pollType === 'singlechoice') {
const id = responseTags[0]?.[1];
const label = id ? optionLabelMap.get(id) : undefined;
return label ? [label] : [];
}
const labels: string[] = [];
const seen = new Set<string>();
for (const [, id] of responseTags) {
if (id && !seen.has(id)) {
seen.add(id);
const label = optionLabelMap.get(id);
if (label) labels.push(label);
}
}
return labels;
}, [vote.tags, pollType, optionLabelMap]);
return (
<Link
to={`/${nevent}`}
onClick={() => {
// Close any open dialogs by dispatching escape
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
}}
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
>
<Avatar shape={avatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">
{authorData?.event ? (
<EmojifiedText tags={authorData.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={vote.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
<div className="flex items-center gap-2">
{votedOptions.length > 0 && (
<span className="text-xs text-muted-foreground truncate">
{votedOptions.join(', ')}
</span>
)}
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(vote.created_at)}</span>
</div>
</div>
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+46 -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,12 @@ 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];
/** Maximum number of media tiles shown in the sidebar. */
const SIDEBAR_MEDIA_LIMIT = 9;
/** Simple email regex for display purposes. */
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -65,10 +73,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 +491,49 @@ 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();
// Single query: fetch media-native events, then fill remaining slots with kind 1 media if needed.
const { data: sidebarEvents, isPending: mediaLoading } = useQuery({
queryKey: ['sidebar-media', pubkey ?? ''],
queryFn: async ({ signal }) => {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
const now = Math.floor(Date.now() / 1000);
const primaryEvents = await nostr.query(
[{ kinds: SIDEBAR_MEDIA_KINDS, authors: [pubkey!], limit: SIDEBAR_MEDIA_LIMIT }],
{ signal: querySignal },
);
const primary = primaryEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
// Only fetch kind 1 fallback if there aren't enough media-native events.
if (primary.length >= SIDEBAR_MEDIA_LIMIT) return primary;
const fallbackEvents = await nostr.query(
[{ kinds: [1], authors: [pubkey!], search: 'media:true', limit: SIDEBAR_MEDIA_LIMIT } as { kinds: number[]; authors: string[]; search: string; limit: number }],
{ signal: querySignal },
);
const fallback = fallbackEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
return [...primary, ...fallback];
},
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 +643,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
)}
{/* Footer — hidden when used as a fields-only preview */}
{mediaEvents !== undefined && <LinkFooter />}
{pubkey !== undefined && <LinkFooter />}
</aside>
);
}
+4 -5
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
import { isCustomEmoji } from '@/lib/customEmoji';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -212,12 +213,10 @@ export function QuickReactMenu({
title={`React with ${isCustom ? shortcode : emoji}`}
>
{customUrl ? (
<img
src={customUrl}
alt={emoji}
<CustomEmojiImg
name={shortcode ?? emoji}
url={customUrl}
className="size-6 object-contain"
loading="lazy"
decoding="async"
/>
) : (
emoji
+12 -2
View File
@@ -12,6 +12,10 @@ interface ReplyContextProps {
pubkeys: string[];
/** Hex event ID of the parent post being replied to. */
parentEventId?: string;
/** Relay URL hint for fetching the parent event. */
parentRelayHint?: string;
/** Author pubkey hint for NIP-65 outbox resolution of the parent event. */
parentAuthorHint?: string;
className?: string;
}
@@ -20,7 +24,7 @@ interface ReplyContextProps {
* When parentEventId is provided, hovering over the line shows an embedded preview of the parent post.
* Used consistently across NoteCard and notification views.
*/
export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContextProps) {
export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAuthorHint, className }: ReplyContextProps) {
// Filter out any undefined/empty pubkeys defensively
const validPubkeys = pubkeys.filter(Boolean);
// Show max 2 authors for cleaner UI
@@ -38,7 +42,13 @@ export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContext
className="w-80 p-0 rounded-2xl shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<EmbeddedNote eventId={parentEventId} className="border-0 rounded-none" disableHoverCards />
<EmbeddedNote
eventId={parentEventId}
relays={parentRelayHint ? [parentRelayHint] : undefined}
authorHint={parentAuthorHint}
className="border-0 rounded-none"
disableHoverCards
/>
</HoverCardContent>
</HoverCard>
) : (
+2 -2
View File
@@ -255,7 +255,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
if (compact) {
return (
<div className="mt-2 space-y-2.5">
<div className="space-y-2.5">
{/* Header: icon + name + summary */}
<div className="flex items-start gap-3">
{icon ? (
@@ -326,7 +326,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
// Full detail view
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
{/* Header: icon + name + summary */}
<div className="flex items-start gap-4">
{icon ? (
+751
View File
@@ -0,0 +1,751 @@
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 Markdown from 'react-markdown';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
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';
/** Sanitize schema allowing only the subset needed for a CHANGELOG. */
const CHANGELOG_SANITIZE_SCHEMA = {
...defaultSchema,
tagNames: ['h1', 'h2', 'h3', 'ul', 'ol', 'li', 'p', 'strong', 'em', 'code', 'br'] as string[],
attributes: {},
};
/** 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="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 — rendered as Markdown, clamped to 4 lines */}
{releaseNotes && (
<div className="prose prose-sm dark:prose-invert max-w-none text-sm leading-relaxed text-muted-foreground line-clamp-4
[&_h1]:text-sm [&_h1]:font-semibold
[&_h2]:text-sm [&_h2]:font-semibold
[&_h3]:text-sm [&_h3]:font-semibold
[&_ul]:pl-4 [&_ul]:list-disc
[&_ol]:pl-4 [&_ol]:list-decimal
[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono
[&_p]:my-0 [&_li]:my-0 [&_h1]:my-0 [&_h2]:my-0 [&_h3]:my-0">
<Markdown rehypePlugins={[[rehypeSanitize, CHANGELOG_SANITIZE_SCHEMA]]}>
{releaseNotes}
</Markdown>
</div>
)}
</div>
);
}
// Full detail view
return (
<div className="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>
<div className="prose prose-sm dark:prose-invert max-w-none text-sm leading-relaxed
[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1
[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1
[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc
[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal
[&_li]:my-0.5
[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono
[&_p]:my-1
first:[&>*]:mt-0">
<Markdown rehypePlugins={[[rehypeSanitize, CHANGELOG_SANITIZE_SCHEMA]]}>
{releaseNotes}
</Markdown>
</div>
</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="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="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="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="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 };
+285 -163
View File
@@ -15,6 +15,7 @@ import {
HardDrive,
Trash2,
ChevronRight,
ChevronDown,
} from 'lucide-react';
import slugify from 'slugify';
import { useNostr } from '@nostrify/react';
@@ -35,6 +36,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { TabButton } from '@/components/TabButton';
import { FabButton } from '@/components/FabButton';
import { toast } from '@/hooks/useToast';
@@ -43,6 +45,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDrafts, type Draft } from '@/hooks/useDrafts';
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
import { useKeyboardVisible } from '@/hooks/useKeyboardVisible';
import { useIsMobile } from '@/hooks/useIsMobile';
import { saveDraft as saveLocalDraft, deleteDraftBySlug, deleteLocalDraftById, getLocalDrafts } from '@/lib/localDrafts';
import type { ArticleFields } from '@/lib/articleHelpers';
import { MilkdownEditor } from './MilkdownEditor';
@@ -64,7 +68,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
const { user } = useCurrentUser();
const { mutate: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, isSaving: isSyncingToRelay, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
const { articles: publishedArticles } = usePublishedArticles();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -79,9 +83,15 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const [isEditMode, setIsEditMode] = useState(editMode);
const [originalSlug, setOriginalSlug] = useState<string | null>(
editMode && initialData?.slug ? initialData.slug : null,
);
const [originalPublishedAt, setOriginalPublishedAt] = useState<number | null>(
initialData?.publishedAt ?? null,
);
const [metadataExpanded, setMetadataExpanded] = useState(false);
const keyboardVisible = useKeyboardVisible();
const isMobile = useIsMobile();
const [article, setArticle] = useState<ArticleData>({
title: initialData?.title || '',
@@ -99,35 +109,35 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
const mountedRef = useRef(true);
useEffect(() => () => { mountedRef.current = false; }, []);
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save. */
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save.
* Always saves locally first so the draft appears immediately in "My Articles",
* then syncs to the relay in the background. */
const persistDraft = useCallback(async (data: ArticleData, { silent }: { silent?: boolean } = {}) => {
// Always persist locally so the draft is visible immediately
saveLocalDraft(data);
setLocalDrafts(getLocalDrafts());
// Mark as saved immediately after the local write — the relay sync
// happens in the background and shouldn't leave the "unsaved" dot visible.
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (user) {
try {
await saveRelayDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved to Nostr relays.' });
}
} catch (error) {
console.error('Failed to save draft to relay:', error);
saveLocalDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved locally', description: 'Could not sync to relays. Saved to your browser.', variant: 'destructive' });
}
}
} else {
saveLocalDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
}
} else if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
}
}, [user, saveRelayDraft]);
@@ -149,6 +159,13 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
};
}, [hasUnsavedChanges, persistDraft]);
/** Silently save the current draft on blur — uses the ref so it's always current. */
const handleBlurSave = useCallback(() => {
const current = articleRef.current;
if (!current.title && !current.content) return;
persistDraft(current, { silent: true });
}, [persistDraft]);
// Reference to handlers for keyboard shortcuts
const handlePublishRef = useRef<(() => void) | null>(null);
const handleSaveDraftRef = useRef<(() => void) | null>(null);
@@ -221,6 +238,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
});
slugManuallyEdited.current = !!item.slug;
setIsEditMode(isPublishedArticle);
setOriginalSlug(isPublishedArticle ? item.slug : null);
setOriginalPublishedAt(item.publishedAt ?? null);
setHasUnsavedChanges(false);
setActiveTab('write');
@@ -419,10 +437,11 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
return;
}
// In edit mode we're intentionally overwriting, so skip the collision check
if (!isEditMode) {
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
// Collision check: only block when the slug would overwrite a *different*
// published article. When editing an existing article with the same slug
// we're intentionally updating it, so skip the check.
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
if (slug !== originalSlug) {
try {
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
@@ -442,7 +461,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
}
doPublish();
}, [user, article, isEditMode, nostr, doPublish]);
}, [user, article, originalSlug, nostr, doPublish]);
// Set refs for keyboard shortcuts
handlePublishRef.current = handlePublish;
@@ -496,7 +515,10 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
Editing
</span>
) : (
<span className="text-blue-600 dark:text-blue-400 text-sm">Editing</span>
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400 text-sm">
<Cloud className={`size-3.5 ${isSyncingToRelay ? 'animate-pulse' : ''}`} />
Editing
</span>
)
) : hasUnsavedChanges ? (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
@@ -504,16 +526,19 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
Unsaved
</span>
) : lastSaved ? (
<span className="text-sm text-muted-foreground">Saved</span>
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Cloud className={`size-3.5 ${isSyncingToRelay ? 'animate-pulse' : ''}`} />
Saved
</span>
) : null;
const totalDrafts = combinedDrafts.length;
return (
<div className="flex flex-col">
{/* Sticky header */}
<div className="sticky top-0 z-20">
<SubHeaderBar pinned className="relative !top-0">
{/* Header — not sticky on mobile in write mode so it scrolls away with content */}
<div className={isMobile && activeTab === 'write' ? 'relative z-20' : 'sticky top-0 z-20'}>
<SubHeaderBar pinned className={isMobile && activeTab === 'write' ? 'relative !static' : 'relative !top-0'}>
<button
onClick={handleBack}
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
@@ -534,6 +559,8 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
/>
</SubHeaderBar>
</div>
{/* Spacer for the arc overhang */}
<div style={{ height: ARC_OVERHANG_PX }} />
<input
ref={fileInputRef}
@@ -544,166 +571,259 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
/>
{/* ── New article tab ──────────────────────────────────────── */}
{activeTab === 'write' && (
<div className="px-4 py-6 pb-24 space-y-6">
{/* Header Image */}
{article.image ? (
<div className="relative rounded-xl overflow-hidden group">
<img
src={article.image}
alt="Header"
className="w-full h-48 sm:h-64 object-cover"
/>
{/* Desktop: centered overlay on hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
<Button
variant="secondary"
size="sm"
<div className={`px-4 pb-24 space-y-4 sm:space-y-6 ${keyboardVisible ? 'py-2' : 'py-4 sm:py-6'}`}>
{/* Header Image — hide when keyboard is visible on mobile */}
{!(isMobile && keyboardVisible) && (
<>
{article.image ? (
<div className="relative rounded-xl overflow-hidden group">
<img
src={article.image}
alt="Header"
className="w-full h-48 sm:h-64 object-cover"
/>
{/* Desktop: centered overlay on hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Image className="w-4 h-4 mr-2" />
)}
Change Image
</Button>
</div>
{/* Mobile: persistent corner button */}
<Button
variant="secondary"
size="icon"
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Image className="w-4 h-4" />
)}
</Button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-8 h-8 animate-spin mb-2" />
) : (
<Image className="w-4 h-4 mr-2" />
<Image className="w-8 h-8 mb-2" />
)}
Change Image
</Button>
</div>
{/* Mobile: persistent corner button */}
<Button
variant="secondary"
size="icon"
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Image className="w-4 h-4" />
)}
</Button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
>
{isUploading ? (
<Loader2 className="w-8 h-8 animate-spin mb-2" />
) : (
<Image className="w-8 h-8 mb-2" />
<span className="text-sm">Add a header image</span>
</button>
)}
<span className="text-sm">Add a header image</span>
</button>
</>
)}
{/* Title */}
{/* Title — always visible, slightly smaller when keyboard is up on mobile */}
<input
type="text"
value={article.title}
onChange={(e) => updateArticle('title', e.target.value)}
onBlur={handleBlurSave}
placeholder="Your article title..."
className="w-full text-3xl sm:text-4xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40"
className={`w-full font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40 ${
isMobile && keyboardVisible ? 'text-xl' : 'text-3xl sm:text-4xl'
}`}
/>
{/* Metadata — inline between title and body */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="summary" className="text-muted-foreground text-sm">Summary</Label>
<Textarea
id="summary"
value={article.summary}
onChange={(e) => updateArticle('summary', e.target.value)}
placeholder="A brief description of your article..."
rows={2}
className="resize-none"
/>
</div>
{/* Metadata — collapsible on mobile, always expanded on desktop */}
{isMobile ? (
<>
<button
type="button"
onClick={() => setMetadataExpanded((v) => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
>
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${metadataExpanded ? 'rotate-0' : '-rotate-90'}`} />
<span>Details</span>
{(article.summary || article.tags.length > 0) && (
<span className="text-muted-foreground/60">
({[article.summary && 'summary', article.tags.length > 0 && `${article.tags.length} tags`].filter(Boolean).join(', ')})
</span>
)}
</button>
{metadataExpanded && (
<div className="space-y-3 animate-in slide-in-from-top-1 duration-200">
<div className="space-y-1.5">
<Label htmlFor="summary" className="text-muted-foreground text-xs">Summary</Label>
<Textarea
id="summary"
value={article.summary}
onChange={(e) => updateArticle('summary', e.target.value)}
placeholder="A brief description of your article..."
rows={2}
className="resize-none text-sm"
/>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
<Input
id="slug"
value={article.slug}
onChange={(e) => {
slugManuallyEdited.current = true;
updateArticle('slug', e.target.value);
}}
placeholder="article-url-slug"
className="h-8 font-mono text-xs"
<div className="space-y-1.5">
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
<Input
id="slug"
value={article.slug}
onChange={(e) => {
slugManuallyEdited.current = true;
updateArticle('slug', e.target.value);
}}
placeholder="article-url-slug"
className="h-8 font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
<Hash className="w-3 h-3 shrink-0" />
Tags
</Label>
<div className="flex gap-1.5">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
}
placeholder="Add a tag..."
className="h-8 text-xs flex-1"
/>
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
<span className="text-base leading-none">+</span>
</Button>
</div>
</div>
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{article.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-0.5 text-xs">
#{tag}
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
)}
</>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="summary" className="text-muted-foreground text-sm">Summary</Label>
<Textarea
id="summary"
value={article.summary}
onChange={(e) => updateArticle('summary', e.target.value)}
placeholder="A brief description of your article..."
rows={2}
className="resize-none"
/>
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
<Hash className="w-3 h-3 shrink-0" />
Tags
</Label>
<div className="flex gap-1.5">
<div className="flex flex-col sm:flex-row gap-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
}
placeholder="Add a tag..."
className="h-8 text-xs flex-1"
id="slug"
value={article.slug}
onChange={(e) => {
slugManuallyEdited.current = true;
updateArticle('slug', e.target.value);
}}
placeholder="article-url-slug"
className="h-8 font-mono text-xs"
/>
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
<span className="text-base leading-none">+</span>
</Button>
</div>
<div className="space-y-1.5 flex-1">
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
<Hash className="w-3 h-3 shrink-0" />
Tags
</Label>
<div className="flex gap-1.5">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
}
placeholder="Add a tag..."
className="h-8 text-xs flex-1"
/>
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
<span className="text-base leading-none">+</span>
</Button>
</div>
</div>
</div>
</div>
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
#{tag}
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
#{tag}
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
)}
{/* Editor */}
<MilkdownEditor
value={article.content}
onChange={(value) => updateArticle('content', value || '')}
onBlur={handleBlurSave}
onUploadImage={handleImageUpload}
placeholder="Start writing your article..."
className="rounded-xl border border-border bg-card min-h-[250px] sm:min-h-[400px]"
className={`rounded-xl border border-border bg-card ${
isMobile && keyboardVisible ? 'min-h-[150px]' : 'min-h-[250px] sm:min-h-[400px]'
}`}
/>
{/* Stats + Save */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<span className="shrink-0">{wordCount} words</span>
<span>·</span>
<span className="shrink-0">{readingTime} min read</span>
{statusLabel && (
<>
<span>·</span>
{statusLabel}
</>
)}
{/* Stats + Save — hide when keyboard is visible on mobile */}
{!(isMobile && keyboardVisible) && (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<span className="shrink-0">{wordCount} words</span>
<span>·</span>
<span className="shrink-0">{readingTime} min read</span>
{statusLabel && (
<>
<span>·</span>
{statusLabel}
</>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveDraft}
className="rounded-full gap-1.5 shrink-0"
>
<Save className="size-3.5" />
Save Draft
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSaveDraft}
className="rounded-full gap-1.5 shrink-0"
>
<Save className="size-3.5" />
Save Draft
</Button>
</div>
)}
</div>
)}
@@ -816,18 +936,20 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
</div>
)}
{/* Publish FAB — mobile: fixed bottom right */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
{/* Publish FAB — mobile: fixed bottom right, hidden when keyboard is up */}
{!keyboardVisible && (
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
)}
{/* Publish FAB — desktop: sticky within column */}
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
+27 -8
View File
@@ -15,6 +15,7 @@ import { LinkDialog } from './LinkDialog';
interface MilkdownEditorInnerProps {
value: string;
onChange: (markdown: string) => void;
onBlur?: () => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
showToolbar?: boolean;
@@ -22,7 +23,7 @@ interface MilkdownEditorInnerProps {
onToggleSource?: () => void;
}
function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
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);
@@ -35,10 +36,12 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
const selectionRef = useRef<{ from: number; to: number } | null>(null);
// Keep ref updated
// 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;
}, [onUploadImage]);
}, [value, onUploadImage]);
const { get } = useEditor((root) => {
const editor = Editor.make()
@@ -146,15 +149,27 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
return () => dom.removeEventListener('blur', check);
}, [get]);
// Handle external value changes (e.g., loading a draft)
// 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) {
// Only update if the value changed externally (not from user typing)
editor.action(replaceAll(value));
try {
editor.action(replaceAll(value));
} catch {
// editorView may not be ready yet (e.g. first render); ignore
return;
}
lastExternalValue.current = value;
}
}, [value, get]);
}, [value, get, sourceMode]);
// Handle link dialog open
const handleLinkButtonClick = useCallback(() => {
@@ -322,6 +337,7 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
<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}
@@ -329,6 +345,7 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
) : (
<div
className="milkdown-content"
onBlur={onBlur}
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
>
<Milkdown />
@@ -347,13 +364,14 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
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, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
export function MilkdownEditor({ value, onChange, onBlur, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
const [sourceMode, setSourceMode] = useState(false);
return (
@@ -362,6 +380,7 @@ export function MilkdownEditor({ value, onChange, onUploadImage, placeholder, cl
<MilkdownEditorInner
value={value}
onChange={onChange}
onBlur={onBlur}
onUploadImage={onUploadImage}
placeholder={placeholder}
showToolbar={showToolbar}
+4 -4
View File
@@ -4,6 +4,7 @@ import { Sticker, Info } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
interface StickerPickerProps {
@@ -54,11 +55,10 @@ export function StickerPicker({ onSelect }: StickerPickerProps) {
onClick={() => onSelect(emoji)}
className="aspect-square rounded-xl overflow-hidden hover:bg-muted/80 transition-all p-1.5 group active:scale-90"
>
<img
src={emoji.url}
alt={emoji.shortcode}
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
loading="lazy"
/>
</button>
))}
+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}
+1 -1
View File
@@ -188,7 +188,7 @@ export interface AppConfig {
homePage: string;
/** Display name used in the NIP-89 "client" tag. Falls back to `appName` when not set. */
clientName?: string;
/** NIP-89 addr (`31990:<pubkey>:<d-tag>`) identifying this client's handler event. Included as the third element of the "client" tag. */
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990:<pubkey>:<d-tag>` addr and relay hint for the "client" tag per NIP-89. */
client?: string;
/** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
magicMouse: boolean;
+11
View File
@@ -112,6 +112,17 @@ export class LayoutStore {
export const LayoutStoreContext = createContext<LayoutStore | null>(null);
/**
* Provides the center column DOM element so components deep in the tree can
* portal overlays into it (e.g. the nsite preview panel).
*/
export const CenterColumnContext = createContext<HTMLElement | null>(null);
/** Hook to get the center column DOM element. Returns null until the layout has mounted. */
export function useCenterColumn(): HTMLElement | null {
return useContext(CenterColumnContext);
}
/** Context for exposing the scroll-direction hidden state to child components (MobileTopBar, SubHeaderBar). */
export const NavHiddenContext = createContext<boolean>(false);
-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(', ')}`);
+29 -3
View File
@@ -17,6 +17,28 @@ import { EncryptedSettingsSchema } from '@/lib/schemas';
*/
let lastWriteTs: number = 0;
/**
* Persist the lastSync timestamp from encrypted settings into localStorage
* so that InitialSyncGate can decide whether to show a spinner on reload.
* If a local timestamp exists, localStorage is trustworthy and the app can
* render immediately while NostrSync fetches updates in the background.
*/
export function getLocalSettingsSync(pubkey: string): number {
try {
return Number(localStorage.getItem(`ditto:settings-lastSync:${pubkey}`)) || 0;
} catch {
return 0;
}
}
export function setLocalSettingsSync(pubkey: string, lastSync: number): void {
try {
localStorage.setItem(`ditto:settings-lastSync:${pubkey}`, String(lastSync));
} catch {
// localStorage may not be available
}
}
/**
* Complete encrypted app settings stored in NIP-78
*/
@@ -112,10 +134,10 @@ export function useEncryptedSettings() {
return events[0];
},
enabled: !!user,
staleTime: Infinity,
staleTime: 5 * 60 * 1000, // 5 minutes — allows window-focus refetch to pick up cross-device changes
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnWindowFocus: true,
refetchOnReconnect: false,
});
@@ -147,7 +169,7 @@ export function useEncryptedSettings() {
}
},
enabled: !!query.data && !!user,
staleTime: Infinity,
staleTime: 0, // Always re-derive when the upstream event changes
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
@@ -216,6 +238,10 @@ export function useEncryptedSettings() {
queryClient.setQueryData(['parsedSettings', signedEvent.id], updatedSettings);
// Cache is now up to date — pending ref no longer needed
pendingSettings.current = null;
// Persist the sync timestamp so the next page load can skip the spinner
if (user && updatedSettings.lastSync) {
setLocalSettingsSync(user.pubkey, updatedSettings.lastSync);
}
},
});
+2 -2
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.
@@ -64,7 +64,7 @@ export function useEvent(eventId: string | undefined, relays?: string[], authorH
const { nostr } = useNostr();
return useQuery<NostrEvent | null>({
queryKey: ['event', eventId ?? ''],
queryKey: ['event', eventId ?? '', relays ?? [], authorHint ?? ''],
queryFn: async () => {
if (!eventId) return null;
const filter: NostrFilter[] = [{ ids: [eventId], limit: 1 }];
+18 -1
View File
@@ -5,7 +5,7 @@ import { parseBlossomServerList } from "@/lib/appBlossom";
import { EncryptedSettingsSchema } from "@/lib/schemas";
import { useAppContext } from "./useAppContext";
import { useCurrentUser } from "./useCurrentUser";
import type { EncryptedSettings } from "./useEncryptedSettings";
import { type EncryptedSettings, setLocalSettingsSync } from "./useEncryptedSettings";
import {
type MuteListItem,
parseMuteTags,
@@ -206,6 +206,9 @@ export function useInitialSync() {
if (parsed.theme) {
updates.theme = parsed.theme;
}
if (parsed.customTheme) {
updates.customTheme = parsed.customTheme;
}
if (parsed.autoShareTheme !== undefined) {
updates.autoShareTheme = parsed.autoShareTheme;
}
@@ -227,6 +230,15 @@ export function useInitialSync() {
if (parsed.homePage) {
updates.homePage = parsed.homePage;
}
if (parsed.corsProxy) {
updates.corsProxy = parsed.corsProxy;
}
if (parsed.faviconUrl) {
updates.faviconUrl = parsed.faviconUrl;
}
if (parsed.linkPreviewUrl) {
updates.linkPreviewUrl = parsed.linkPreviewUrl;
}
return updates;
});
@@ -242,6 +254,11 @@ export function useInitialSync() {
parsed,
);
// Persist the sync timestamp so future reloads can skip the spinner
if (parsed.lastSync) {
setLocalSettingsSync(user.pubkey, parsed.lastSync);
}
foundSettings = true;
} catch (error) {
console.error(
+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;
}
+32 -2
View File
@@ -1,11 +1,40 @@
import { useNostr } from "@nostrify/react";
import { useMutation, type UseMutationResult } from "@tanstack/react-query";
import { nip19 } from "nostr-tools";
import { useAppContext } from "./useAppContext";
import { useCurrentUser } from "./useCurrentUser";
import type { NostrEvent } from "@nostrify/nostrify";
/**
* Builds a NIP-89 "client" tag from the app display name and an optional
* `naddr1` identifier for the kind 31990 handler event.
*
* Tag format (per NIP-89):
* ["client", <name>, <31990:pubkey:d-tag>, <relay-hint>]
*
* The relay hint is taken from the first relay embedded in the naddr (if any).
*/
function buildClientTag(name: string, clientNaddr: string | undefined): string[] {
if (!clientNaddr) {
return ["client", name];
}
try {
const decoded = nip19.decode(clientNaddr);
if (decoded.type !== "naddr") {
return ["client", name];
}
const { kind, pubkey, identifier, relays } = decoded.data;
const addr = `${kind}:${pubkey}:${identifier}`;
const relayHint = relays?.[0];
return relayHint ? ["client", name, addr, relayHint] : ["client", name, addr];
} catch {
return ["client", name];
}
}
export function useNostrPublish(): UseMutationResult<NostrEvent> {
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -16,9 +45,10 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
if (user) {
const tags = t.tags ?? [];
// Add the client tag if it doesn't exist
// Add the NIP-89 client tag if it doesn't exist
if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) {
tags.push(["client", config.clientName ?? config.appName, ...(config.client ? [config.client] : [])]);
const clientTag = buildClientTag(config.clientName ?? config.appName, config.client);
tags.push(clientTag);
}
const event = await user.signer.signEvent({
+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;
+34
View File
@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useEvent } from '@/hooks/useEvent';
/**
* Given a kind 1018 poll vote event, resolves the human-readable option label(s)
* by fetching the parent poll and mapping response IDs to option names.
*
* Returns an empty string for non-1018 events or if the parent poll hasn't loaded yet.
*/
export function usePollVoteLabel(event: NostrEvent): string {
const parentTag = useMemo(
() => event.kind === 1018 ? event.tags.find(([n]) => n === 'e') : undefined,
[event],
);
const parentId = parentTag?.[1];
const relayHint = parentTag?.[2] || undefined;
const authorHint = parentTag?.[4] || (event.kind === 1018 ? event.tags.find(([n]) => n === 'p')?.[1] : undefined) || undefined;
const { data: parentPoll } = useEvent(parentId, relayHint ? [relayHint] : undefined, authorHint);
return useMemo(() => {
const responseIds = event.kind === 1018
? event.tags.filter(([n]) => n === 'response').map(([, id]) => id)
: [];
if (responseIds.length === 0) return '';
if (!parentPoll) return responseIds.join(', ');
const optMap = new Map<string, string>();
for (const tag of parentPoll.tags) {
if (tag[0] === 'option') optMap.set(tag[1], tag[2]);
}
return responseIds.map((id) => optMap.get(id) ?? id).join(', ');
}, [event, parentPoll]);
}
-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),
});
}
+47
View File
@@ -652,3 +652,50 @@
@apply min-h-[350px];
}
/* Blobbi idle animations — speed/intensity driven by happiness via inline style */
@keyframes blobbi-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes blobbi-sway {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(1.5deg); }
75% { transform: rotate(-1.5deg); }
}
/* Hatch ceremony shake animations */
@keyframes blobbi-shake-light {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-3deg); }
75% { transform: rotate(3deg); }
}
@keyframes blobbi-shake-medium {
0%, 100% { transform: rotate(0deg); }
20% { transform: rotate(-6deg); }
40% { transform: rotate(5deg); }
60% { transform: rotate(-4deg); }
80% { transform: rotate(6deg); }
}
@keyframes blobbi-shake-heavy {
0%, 100% { transform: rotate(0deg) scale(1); }
15% { transform: rotate(-8deg) scale(1.02); }
30% { transform: rotate(7deg) scale(0.98); }
45% { transform: rotate(-9deg) scale(1.03); }
60% { transform: rotate(8deg) scale(0.97); }
75% { transform: rotate(-7deg) scale(1.02); }
90% { transform: rotate(9deg) scale(1); }
}
@keyframes blobbi-flash {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes blobbi-fade-to-white {
0% { opacity: 0; }
100% { opacity: 1; }
}
-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
+4 -3
View File
@@ -478,7 +478,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
id: 'development',
showKey: 'showDevelopment',
feedKey: 'feedIncludeDevelopment',
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 31990],
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 30063, 31990],
label: 'Development',
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
route: 'development',
@@ -546,9 +546,10 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
35128: 'nsite',
30008: 'profile badges',
30817: 'repository issue',
32267: 'app',
32267: 'Zapstore app',
31990: 'app',
30063: 'release',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/**
+42 -4
View File
@@ -14,15 +14,53 @@ export function isReplyEvent(event: NostrEvent): boolean {
return nonMentionTags.length > 0;
}
/** Hints extracted from an `e` tag for relay resolution. */
export interface ParentEventHints {
id: string;
relayHint?: string;
authorHint?: string;
}
/**
* Extracts the parent (replied-to) event ID from an event's tags following NIP-10 conventions.
* Supports both the preferred marked-tag scheme and the deprecated positional scheme.
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
*/
export function getParentEventId(event: NostrEvent): string | undefined {
return getParentEventTag(event)?.[1];
}
/**
* Extracts the parent event ID along with relay and author hints from the `e` tag.
* Returns the full NIP-10 hints (relay URL at position [2], author pubkey at position [4]).
*
* When the `e` tag doesn't include a pubkey at position [4] (many clients omit it),
* falls back to the first `p` tag in the event, which per NIP-10 convention contains
* the pubkey of the author being replied to.
*/
export function getParentEventHints(event: NostrEvent): ParentEventHints | undefined {
const tag = getParentEventTag(event);
if (!tag) return undefined;
// Prefer the pubkey embedded in the e tag (NIP-10 position [4]).
// Fall back to the first p tag, which conventionally holds the parent author's pubkey.
const authorHint = tag[4] || event.tags.find(([name]) => name === 'p')?.[1] || undefined;
return {
id: tag[1],
relayHint: tag[2] || undefined,
authorHint,
};
}
/**
* Returns the raw parent `e` tag from an event following NIP-10 conventions.
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
*/
function getParentEventTag(event: NostrEvent): string[] | undefined {
// NIP-25: for kind 7 reactions, the target event is always the last e-tag
if (event.kind === 7) {
return event.tags.findLast(([name]) => name === 'e')?.[1];
return event.tags.findLast(([name]) => name === 'e');
}
// Exclude "mention" e-tags — they are inline quotes, not reply/root references
@@ -31,12 +69,12 @@ export function getParentEventId(event: NostrEvent): string | undefined {
// Preferred: look for marked "reply" tag first
const replyTag = eTags.find(([, , , marker]) => marker === 'reply');
if (replyTag) return replyTag[1];
if (replyTag) return replyTag;
// If there's a "root" marker but no "reply" marker, the event replies directly to root
const rootTag = eTags.find(([, , , marker]) => marker === 'root');
if (rootTag) return rootTag[1];
if (rootTag) return rootTag;
// Deprecated positional scheme: last non-mention e-tag is the reply target
return eTags[eTags.length - 1][1];
return eTags[eTags.length - 1];
}

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