Compare commits

...

199 Commits

Author SHA1 Message Date
Alex Gleason f1daf61a65 Add diagnostics to sbx:// scheme handler to verify if it receives calls 2026-04-12 19:41:57 -05:00
Alex Gleason 02cd63dbd9 Fix iOS sandbox: use sbx:// custom scheme registered at WKWebView config time
Replace the swizzle-based approach with a proper WKURLSchemeHandler
registered via DittoBridgeViewController.webViewConfiguration(for:).
This runs before the WKWebView is created, so sbx:// iframe requests
are intercepted correctly — unlike the previous attempt which
registered the handler too late (in capacitorDidLoad).

Each sandbox loads from sbx://<sandbox-id>/path, giving every sandbox
a unique web origin with full localStorage/cookie isolation.
2026-04-12 19:34:59 -05:00
Alex Gleason 56d65be19a Add swizzle call counter and last URL to diagnose() output 2026-04-12 19:08:59 -05:00
Alex Gleason d23db1e5c2 Add diagnose() calls around iframe.src to debug swizzle on iOS 2026-04-12 19:06:50 -05:00
Alex Gleason 0416b20a46 Fix iOS sandbox: use capacitor:// scheme with swizzled asset handler
Root cause: WKWebView does not invoke WKURLSchemeHandler for iframe
loads when the iframe uses a different scheme than the parent page.
The app uses capacitor:// (iosScheme: 'https' is normalised to
capacitor:// by Capacitor since https is a built-in scheme), but
sandbox iframes were using sbx:// — a cross-scheme load that WebKit
silently ignores.

Fix: Sandbox iframes now use capacitor://<id>.sandbox.local/path
(same scheme as the parent app). Capacitor's WebViewAssetHandler is
the sole handler for the capacitor:// scheme, so we swizzle its
webView(_:start:) and webView(_:stop:) methods at runtime to
intercept requests whose hostname ends with .sandbox.local. These
are forwarded to the JS layer for resolution. All other requests
pass through to the original Capacitor implementation.

This removes the standalone IframeSandboxSchemeHandler and the sbx
scheme registration in DittoBridgeViewController. The swizzle is
installed in SandboxPlugin.load(), keeping the architecture simple:
one plugin, one request handler, no separate scheme.

Also cleans up diagnostic logging from the investigation, retaining
only the diagnose() plugin method for future debugging.
2026-04-12 19:03:42 -05:00
Alex Gleason eae9c1d00d Add diagnose() plugin method to inspect native sandbox state from JS
Since Swift.print/NSLog/CAPLog.print output is not visible in Xcode's
console when running on a physical device, add a diagnose() method to
SandboxPlugin that JS can call to get the native state back through
the Capacitor bridge (which we know works).

Returns: schemeHandlerSet, pluginConnected, schemeHandlerHasWebView,
bridgeHasWebView, hasListenersFetch, pendingTaskCount.

Called from SandboxFrameNative before and 1s after setting iframe.src
so we can see the full native state in the JS console log stream.
2026-04-12 18:46:41 -05:00
Alex Gleason 72cbc871f5 Use Swift.print instead of CAPLog.print for diagnostic logging
CAPLog.print may be suppressed by enableLogging flag. Use raw
Swift.print for the baseline diagnostics so they always output to
stdout regardless of Capacitor's logging configuration.
2026-04-12 18:42:40 -05:00
Alex Gleason c848e8b51e Route native diagnostic logs through JS console via evaluateJavaScript
NSLog output was not visible in Xcode's console panel. Switch to
injecting log messages into the WKWebView's JS console via
evaluateJavaScript so they appear in the same Capacitor log stream
(prefixed with ️) that is already visible.

- DittoBridgeViewController: injects a diagnostic after 2s delay
- IframeSandboxSchemeHandler: captures webView ref, logs to JS console
- SandboxPlugin: logs to JS console via bridge.webView
- All three still also use CAPLog.print as fallback
2026-04-12 18:34:31 -05:00
Alex Gleason 30886bf9fa Add AppDelegate and capacitorDidLoad logging to verify NSLog visibility
Adds baseline NSLog calls in AppDelegate.didFinishLaunchingWithOptions
and DittoBridgeViewController.capacitorDidLoad to confirm whether NSLog
output is reaching the Xcode console at all. Also logs the state of
sharedSchemeHandler and its plugin reference after capacitorDidLoad.
2026-04-12 18:29:48 -05:00
Alex Gleason 156c5f5388 Add diagnostic logging to sandbox iframe pipeline for iOS debugging
Instrument every stage of the native sandbox iframe flow to pinpoint
why iOS shows a blank screen:

- DittoBridgeViewController: log scheme handler registration
- IframeSandboxSchemeHandler: log request interception, plugin ref
  status, pending task counts, response delivery, and base64 decode
- SandboxPlugin: log load/init, respondToFetch calls, event emission
  with hasListeners status
- SandboxFrameNative (React): log setup lifecycle, fetch events
  received/skipped, iframe src assignment, postMessage RPC, and
  iframe onLoad event

All logging uses NSLog on iOS (visible in Xcode console) and
console.log/error on the JS side (visible in Safari Web Inspector).
2026-04-12 18:21:15 -05:00
Alex Gleason e7d35c71c6 Allow clipboard-write in sandbox iframes 2026-04-12 18:10:08 -05:00
Alex Gleason a074e7c730 Fix nsite nav bar spacing on native by separating safe area from content padding 2026-04-12 18:08:35 -05:00
Alex Gleason 9c70c2b42b Restore nsite card pin button and auto-play state cleanup
Re-add the Pin/Unpin button on NsiteCard and the useEffect in
PostDetailPage that clears router state after consuming nsiteAutoPlay,
both lost during the sandbox refactor squash.
2026-04-12 17:54:44 -05:00
Alex Gleason 9822fd2a0b Replace native WebView overlay with iframe served by native code
On native platforms, sandbox content now renders in a regular <iframe>
instead of a separate native WebView overlaid on top of the Capacitor
web layer. This fixes permission prompts, popovers, and other web UI
being hidden behind the native WebView.

iOS: Register a single WKURLSchemeHandler for the 'sbx' scheme on the
main Capacitor WKWebView. Each sandbox iframe loads from
sbx://<sandbox-id>/path — different hostnames = different origins with
isolated localStorage/IndexedDB.

Android: Subclass BridgeWebViewClient to intercept requests to
*.sandbox.native from iframes in the main WebView. Same origin
isolation via hostname differentiation.

React: SandboxFrameNative now renders a real <iframe> element with
standard postMessage communication, eliminating the need for native
WebView creation, positioning, resize observation, and the native
bridge script injection. A loading spinner overlay is shown until the
iframe content loads.
2026-04-12 17:51:24 -05:00
Alex Gleason c1147063c6 Add nsite:// sidebar pinning with auto-launch, favicon, and highlight
Introduce a new sidebar item type for nsites that auto-opens the nsite
preview when clicked, using React Router state to prevent external URLs
from triggering auto-launch.

- Add isNsiteUri/nsiteUriToSubdomain helpers and parseNsiteSubdomain
- Create NsiteSidebarItem with site favicon, link preview title label
- Wire nsite:// dispatch in SidebarNavList, useFeedSettings, SidebarMoreMenu
- NoteMoreMenu pins named nsite events as nsite:// URIs instead of nostr: URIs
- NsiteCard accepts autoPlayKey prop; useEffect re-opens on repeated clicks
- NsitePlayerContext tracks active subdomain for sidebar highlighting
- Provided in MainLayout so sidebar and pages share state
2026-04-12 16:45:30 -05:00
Alex Gleason eacb0e4371 Show site favicon in nsite permission prompt instead of shield icon 2026-04-12 16:00:21 -05:00
Alex Gleason 647b3d414d Show site favicon in nsite preview nav bar instead of generic package icon 2026-04-12 15:58:25 -05:00
Alex Gleason ad7f053129 Centralize kind labels into src/lib/kindLabels.ts
Add a comprehensive KIND_LABELS registry covering all kinds from the NIP
README table, Ditto reference page, and existing codebase maps. Labels
use short user-facing names (e.g. "Photo" not "Picture", "App" not
"Handler information", "Zapstore app" not "Software application").

Consumers updated to import from the central registry:
- signerWithNudge.ts: falls through to central registry after overrides
- nsitePermissions.ts: removed local KIND_LABELS, re-exports getKindLabel
- ExternalContentHeader.tsx: removed WELL_KNOWN_KIND_LABELS
- PostDetailPage.tsx: shellTitleForKind falls through to central registry

AGENTS.md updated to document the central registry and its relationship
to context-specific maps (CommentContext, NotificationsPage, NoteCard).
2026-04-12 15:53:21 -05:00
Alex Gleason 075025bceb Inject NIP-07 signer into nsite previews with permission system
When a logged-in user runs an nsite, a window.nostr provider is injected
into the sandboxed iframe. The provider proxies signEvent, nip04, and
nip44 calls to the parent signer over the existing JSON-RPC postMessage
bridge.

A permission system gates each operation:
- getPublicKey is auto-allowed (clicking Run implies consent)
- signEvent prompts are granular per event kind (like Amber)
- encrypt/decrypt prompts are per operation type
- Users can check "Remember for this site" to persist decisions
- Permissions are scoped to (userPubkey, siteId) in localStorage

The nsite preview nav bar gains a shield icon that opens a popover for
managing stored permissions (toggle, remove, revoke all).

New files:
- src/lib/nsitePermissions.ts: permission model + persistence
- src/lib/nsiteNostrProvider.ts: injected NIP-07 provider script
- src/hooks/useNsiteSignerRpc.ts: RPC handler with permission gating
- src/components/NsitePermissionPrompt.tsx: approval dialog overlay
- src/components/NsitePermissionManager.tsx: permission management popover
2026-04-12 15:31:58 -05:00
Alex Gleason 32908f7b4f release: v2.6.6 2026-04-12 14:32:14 -05:00
Alex Gleason bd333b9584 Fix Android WebView resize bugs caused by @capacitor/keyboard
Remove resizeOnFullScreen config which caused possiblyResizeChildOfContent()
to corrupt CoordinatorLayout height on Android 16 (API 36). Upgrade plugin
from 8.0.2 to 8.0.3 which adds a SystemBars guard as additional safety.
Platform-gate setAccessoryBarVisible to iOS only (unimplemented on Android).
2026-04-12 14:07:52 -05:00
Alex Gleason 3ac1dc6b0a Fix dialog obscured by virtual keyboard on Android Chrome
Add interactive-widget=resizes-content to the viewport meta tag so
Chrome on Android resizes the layout viewport when the on-screen
keyboard opens. This keeps fixed-position dialogs (compose, reply,
login, etc.) centered in the visible area above the keyboard.
2026-04-12 13:21:21 -05:00
Alex Gleason 025ecd8645 Upgrade nostrify: improve NIP-46 signing reliability 2026-04-12 12:02:45 -05:00
Alex Gleason 0fca39a1bd Remove androidResume utility and its foreground-resume retry logic
The visibility-change-based Android resume detection was causing more
problems than it solved. Remove the module and simplify LoginDialog and
signerWithNudge to operate without retry-on-resume behavior.
2026-04-12 11:37:10 -05:00
Chad Curtis 3152f7f0ec Merge branch 'fix/emoji-shortcode-autocomplete' into 'main'
Fix emoji shortcode autocomplete clipped by compose box and emojis rendering as text

Closes #216

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

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

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

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

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

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

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

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

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

- Replace @capacitor/status-bar with SystemBars from @capacitor/core 8+
- Enable insetsHandling: 'css' in capacitor.config.ts so the SystemBars
  plugin injects --safe-area-inset-* CSS variables on Android
- Update all safe area CSS utilities and inline styles to use
  var(--safe-area-inset-*, env(safe-area-inset-*, 0px)) fallback pattern
- Remove @capacitor/status-bar dependency (no longer needed)
2026-04-11 14:47:15 -05:00
Alex Gleason 207ca6893a Add iCloud Keychain credential saving/restoring on iOS via @capgo/capacitor-autofill-save-password
- Use SecAddSharedWebCredential to prompt 'Save Password?' on signup
- Use ASAuthorizationPasswordProvider to restore credentials on login
- Add webcredentials:ditto.pub Associated Domains entitlement
- Deploy apple-app-site-association for domain validation
- Keep existing Chromium PasswordCredential flow as web fallback
- Add saveNsec() helper: native credential manager on iOS/Android,
  file download + bonus PasswordCredential on web
- Single 'Continue' button triggers the appropriate save method per platform
2026-04-11 14:01:34 -05:00
Alex Gleason 37df5d0bd1 release: v2.6.3 2026-04-10 23:27:38 -05:00
Alex Gleason 19906cf918 Merge branch 'fix/badge-image-aspect-ratio-hint' into 'main'
Show recommended 1:1 aspect ratio hint on badge image upload

Closes #212

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

Safari/Firefox/iOS silently skip — existing flows are unchanged.
2026-04-10 21:49:14 -05:00
Alex Gleason 126dce1dfc Surface account deletion as 'Delete Account' for App Store compliance
Add a 'Delete Account' pill button to the bottom of the Settings
page (Guideline 5.1.1v). Rename the Danger Zone heading in Advanced
Settings to match. Simplify the deletion dialog to a single screen:
plain-language warning, list of what gets deleted, type DELETE to
confirm, and Cancel/Delete buttons. Always broadcasts to all relays.

The underlying NIP-62 mechanism and components that render vanish
events to other users are unchanged.
2026-04-10 16:44:35 -05:00
Alex Gleason 105da53e2e Add NSCameraUsageDescription to Info.plist
File inputs with accept="image/*" present a camera option on iOS.
Without this usage description, WKWebView crashes or fails to show
the permission dialog when the user selects 'Take Photo'.
2026-04-10 16:10:35 -05:00
Alex Gleason 7bc4a632b0 Add XCode DEVELOPMENT_TEAM to project.pbxproj 2026-04-10 16:03:38 -05:00
Alex Gleason 0222248d76 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-10 15:49:15 -05:00
Alex Gleason a542dd3b36 Sanitize all event-sourced URLs and prevent CSS injection
Nostr events are untrusted user input. Any URL extracted from event tags
or metadata must be validated before use in any context — not just
navigable hrefs, but also img src, CSS url(), and style attributes.

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

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

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

Document sanitizeUrl usage in AGENTS.md for future agent use.
2026-04-10 14:22:42 -05:00
Mary Kate Fain 173f789242 Extract shared portal dropdown logic into usePortalDropdown hook
Both EmojiShortcodeAutocomplete and MentionAutocomplete had identical
logic for fixed viewport positioning with viewport-flip, scroll/resize
dismissal, and portal rendering. Extract into a shared hook to reduce
duplication and centralize the positioning behavior.
2026-04-10 12:50:05 -05:00
Chad Curtis 72268dfde6 Merge branch 'feat/feed-blobbi-status-visuals' into 'main'
Reflect companion condition in feed Blobbi cards

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #167

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- NsitePreviewDialog: remove nsiteUrl proxy, accept NostrEvent instead
- NsiteCard: pass event directly to dialog
- AppHandlerContent: use useAddrEvent to fetch the kind 35128 event by
  pubkey+d-tag from the 'a' tag, then pass the event to the dialog; disable
  Run button until the nsite event is loaded; remove unused hexToBase36
2026-04-03 18:10:22 -05:00
Alex Gleason 357dd56de0 Merge branch 'main' of gitlab.com:soapbox-pub/ditto 2026-04-03 17:54:42 -05:00
Alex Gleason fadec0574a Add Run button to NsiteCard for in-app nsite preview 2026-04-03 17:49:06 -05:00
Alex Gleason 469806886a Fix card navigation firing on button/link clicks in NoteCard 2026-04-03 17:45:36 -05:00
Alex Gleason f7ab980ecd Fix nsite preview panel height using measured column rect
Replace absolute/sticky positioning with fixed + inline styles derived
from a ResizeObserver on the center column element. The panel now sits
at exactly the column's left/top/width and fills to the bottom of the
viewport, unaffected by the column's pb-overscroll padding.
2026-04-03 17:41:09 -05:00
Alex Gleason c6b5ab2284 Replace address bar and external link with app icon and name in preview nav bar 2026-04-03 17:36:17 -05:00
Alex Gleason 2231673ee6 Fix nsite preview panel to fill exactly the center column
Add CenterColumnContext to LayoutContext and expose the center column DOM
element from MainLayout via a useState ref callback. NsitePreviewDialog now
portals into that element using absolute inset-0 instead of fixed positioning
with hardcoded sidebar insets, so it always covers exactly the center column
regardless of viewport width.
2026-04-03 17:30:00 -05:00
Alex Gleason f8907475f9 Link client tag name to /:naddr on post detail page 2026-04-03 17:28:29 -05:00
Alex Gleason 4252841125 Replace dialog with fixed center-column overlay panel for nsite preview
Remove the Radix Dialog and browser chrome (back/forward/refresh/fullscreen).
The preview now renders as a portal-based fixed panel that overlays exactly
the center column using responsive left/right insets matching the sidebar
widths (sidebar:left-[300px], xl:right-[300px]). A slim nav bar at the top
shows the nsite:// URL, an external-link button, and a close button.
2026-04-03 17:25:55 -05:00
Alex Gleason ee8220c1f0 Use nsite://<name><path> in preview address bar
Separate the proxy target (nsite.lol gateway URL) from the display URL.
Pass nsiteName through to the dialog so the address bar shows a clean
nsite:// scheme with no gateway hostname.
2026-04-03 17:11:45 -05:00
Alex Gleason 11e29646a7 Show nsite:// URL in preview address bar instead of gateway URL 2026-04-03 17:09:03 -05:00
Alex Gleason a9bab7f8e8 Remove default dialog close button from nsite preview 2026-04-03 17:03:09 -05:00
Alex Gleason 0b69ab51f4 Fix Content-Type header matching in nsite preview proxy
The iframe-fetch-client does an exact equality check for "text/html",
but real servers return "text/html; charset=UTF-8". Also, the browser
fetch() API lowercases all header names while main.js checks Title-Case
keys. Fix both: re-key headers to Title-Case and strip charset params
from Content-Type values before sending them to the iframe.
2026-04-03 17:01:59 -05:00
Alex Gleason 2a32e79b13 feat: change AppConfig.client to naddr1 format, decode relay hint per NIP-89
AppConfig.client now expects a NIP-19 naddr1 string pointing to the app's
kind 31990 handler event instead of a raw 'a' tag value. useNostrPublish
decodes the naddr at publish time to extract the 31990:<pubkey>:<d-tag>
addr and any embedded relay hint, producing a fully NIP-89-compliant
client tag: ["client", <name>, <addr>, <relay-hint>].
2026-04-03 16:57:57 -05:00
Alex Gleason 39fc7549ac Add Run button and nsite preview dialog to app handler cards
When a kind 31990 app event includes an 'a' tag pointing to a kind 35128
nsite, display a 'Run' button that opens an in-app preview dialog. The
dialog embeds the nsite in a sandboxed iframe via the Shakespeare
iframe-fetch-client protocol (local-shakespeare.dev), proxying fetch
requests from the iframe to the live nsite URL so the SPA renders
without needing CORS headers on the origin server.
2026-04-03 16:53:25 -05:00
Alex Gleason 414f42e339 Add Blobbi (kind 31124) to the Ditto homepage feed 2026-04-03 16:50:17 -05:00
Alex Gleason 8e3f778f5b Improve Zapstore and app handler card display
- Rename Zapstore kind labels to include 'Zapstore' prefix across all
  label registries (NoteCard, PostDetailPage, CommentContext,
  ExternalContentHeader, NotificationsPage, extraKinds)
- Wrap Zapstore (32267, 30063, 3063) compact and detail content in
  rounded bordered cards with hover effects; remove redundant mt-2/mt-3
  margins from component roots
- Replace useLinkPreview thumbnail with metadata banner/picture in kind
  31990 app handler cards (compact and full views)
- Add pt-4 to Zapstore detail card wrappers in PostDetailPage
- Fix sticky tab bar (SubHeaderBar z-10) being painted over by card
  content: remove z-10 from AppHandlerContent inner div and add z-0 to
  the main content column in MainLayout
2026-04-03 16:20:40 -05:00
Alex Gleason bc83d08961 Upgrade Nostrify 2026-04-03 13:56:48 -05:00
Alex Gleason 7d83273410 Simplify sidebar media query to a single useQuery with inline fallback logic 2026-04-03 00:53:45 -05:00
Alex Gleason fabcb4170d Fill profile media sidebar with kind 1 fallback when kind 20 results are sparse
When fewer than 9 media-native events (kind 20, 21, 22, etc.) are found for a
profile, perform a secondary query for kind 1 events with search:media:true and
append them to fill the remaining slots. Kind 20 events are always displayed first.
2026-04-03 00:36:44 -05:00
275 changed files with 15055 additions and 7629 deletions
+112
View File
@@ -0,0 +1,112 @@
---
name: lockdown-mode
description: Apple Lockdown Mode restrictions and their impact on web APIs inside WKWebView/Safari/WebView. Reference when debugging or building features for lockdown-enabled devices.
---
# Apple Lockdown Mode
Apple's Lockdown Mode is an opt-in security hardening profile that disables or restricts many web platform APIs inside Safari and WKWebView. Since this app ships inside a Capacitor WKWebView shell, **every restriction that applies to Safari also applies to our app**.
## Platform Availability
Lockdown Mode is available on:
- **iOS 16** or later (iPhone)
- **iPadOS 16** or later (iPad)
- **watchOS 10** or later (Apple Watch)
- **macOS Ventura** or later (Mac)
Additional protections are available starting in iOS 17, iPadOS 17, watchOS 10, and macOS Sonoma.
For full details, see [About Lockdown Mode](https://support.apple.com/en-us/105120) on Apple Support.
## Testing Baseline
This document is based on testing against **iOS 18.7 / Safari 26.4** on an iPhone with Lockdown Mode enabled (April 2026). The web API restrictions documented below apply to Safari and WKWebView across all supported platforms (iOS, iPadOS, and macOS).
## Blocked APIs
These APIs are **completely unavailable** (return `undefined`, `null`, or throw) when Lockdown Mode is active.
| API | Impact | Notes |
|-----|--------|-------|
| **IndexedDB** | Critical | `indexedDB` global is missing entirely. Any library that relies on IndexedDB for storage will fail (Dexie, idb, localForage with IndexedDB driver, etc.). |
| **Service Workers** | High | `navigator.serviceWorker` is absent. No offline caching, no background sync, no push notifications via SW. |
| **Cache API** | High | `caches` global is absent. Often used alongside Service Workers for offline strategies. |
| **WebAssembly** | High | `WebAssembly` global is `undefined`. Libraries compiled to WASM (e.g. libsodium-wrappers, secp256k1-wasm, SQLite WASM) will not load. |
| **Web Locks** | High | `navigator.locks` is absent. Cross-tab coordination patterns that depend on this will break silently. |
| **WebRTC** | High | `RTCPeerConnection` is absent. No peer-to-peer audio/video/data channels. |
| **WebGL / WebGL2** | Medium | All canvas `getContext('webgl'*)` calls return `null`. GPU-accelerated rendering, maps (Mapbox GL, deck.gl), and 3D are broken. |
| **FileReader** | Medium | `FileReader` constructor is absent. Cannot read `Blob`/`File` objects client-side (e.g. image preview before upload). Use the `File` constructor + `URL.createObjectURL()` as a workaround for previews. |
| **SharedArrayBuffer** | Medium | `SharedArrayBuffer` is `undefined`. May also require COOP/COEP headers on non-lockdown browsers, so this is often already unavailable. |
| **Speech Synthesis** | Low | `window.speechSynthesis` is absent. Text-to-speech features won't work. |
| **Notifications API** | Low | `Notification` is absent. Web push permission prompts won't appear. (Capacitor local notifications via the native plugin are unaffected.) |
| **WebCodecs** | Low | `VideoDecoder` / `VideoEncoder` are absent (`AudioDecoder` remains). Low-level media processing is unavailable. |
| **Gamepad API** | Low | `navigator.getGamepads` is absent. |
| **OPFS** | Medium | `navigator.storage.getDirectory` method does not exist. The `navigator.storage` object is present but the Origin Private File System API is stripped. SQLite-over-OPFS and any other OPFS-based storage will fail. |
| **Web Share API** | Low | `navigator.share` is absent. Use Capacitor's `@capacitor/share` plugin instead -- the native share sheet still works. |
## Available APIs
These APIs **still work** under Lockdown Mode and can be relied on.
| API | Notes |
|-----|-------|
| **File constructor** | `new File(...)` works. You can create File/Blob objects. |
| **FontFace API** | Dynamic font loading via `new FontFace()` succeeds. Remote font fetches may fail with a network error (data URIs rejected). |
| **JIT compilation** | JavaScript JIT appears active (~110ms for 1M iterations). Performance is not interpreter-level degraded. |
| **PDF viewer** | `navigator.pdfViewerEnabled` is `true`. Inline `<embed type="application/pdf">` works. |
| **Cookies** | `navigator.cookieEnabled` is `true`. |
| **Credential Management** | `navigator.credentials` is available. |
| **localStorage / sessionStorage** | Standard Web Storage APIs remain functional. |
## Implications for This App
### Storage
- **localStorage works** -- our primary client-side storage (app config, relay lists, etc.) is unaffected.
- **IndexedDB is gone** -- if any dependency silently uses IndexedDB (e.g. some Nostr caching layers, TanStack Query persisters), it will fail. Ensure all storage paths fall back to localStorage or in-memory.
- **OPFS is gone** -- `navigator.storage.getDirectory` is stripped (the method doesn't exist, though the `navigator.storage` object itself remains). SQLite-over-OPFS (e.g. wa-sqlite, sql.js with OPFS backend) and any other OPFS-based persistence will not work.
### Cryptography
- **WebAssembly is blocked** -- any WASM-based crypto libraries (secp256k1 compiled to WASM, libsodium WASM builds) will not load. Use pure-JS implementations (e.g. `@noble/secp256k1`, `@noble/hashes`) which are already what nostr-tools uses.
- **WebCrypto (`crypto.subtle`)** -- not listed as blocked in testing. The SubtleCrypto API should still be available for NIP-44 encryption via the standard Web Crypto path.
### Media & Rendering
- **WebGL is gone** -- map libraries that require WebGL (Mapbox GL JS, Google Maps WebGL renderer) will show blank canvases. Use raster tile alternatives or static map images.
- **FileReader is gone** -- image/file preview workflows that use `FileReader.readAsDataURL()` need a workaround. Use `URL.createObjectURL(file)` directly for `<img src>` previews instead.
### Communication
- **WebRTC is gone** -- any peer-to-peer features (voice/video calls, WebRTC data channels) are completely unavailable.
- **Fetch / XMLHttpRequest** -- standard network requests appear unaffected. Relay WebSocket connections should work normally.
### Native Plugin Workarounds
Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView restrictions entirely:
| Blocked Web API | Capacitor Alternative |
|---|---|
| Web Share | `@capacitor/share` (already installed) |
| Notifications | `@capacitor/local-notifications` (already installed) |
| File downloads | `@capacitor/filesystem` + share (already implemented in `downloadFile.ts`) |
### Detection
The report used a scoring heuristic (8/12 key APIs blocked = 70%) to detect Lockdown Mode. There is no official API to query Lockdown Mode status. Detection relies on probing for the absence of multiple APIs that are specifically disabled by Lockdown Mode but normally present in Safari.
## Raw Diagnostic Report
For exact error messages, navigator properties, weight scores, and per-API diagnostic output, see [ios-report.txt](ios-report.txt).
## Guidance for Feature Decisions
When building new features, consider:
1. **Always provide pure-JS fallbacks** for any crypto or data-processing library that might ship a WASM build.
2. **Never depend on IndexedDB or OPFS** as the sole storage mechanism. Both are completely stripped. Always fall back to localStorage or in-memory stores.
3. **Avoid WebGL-dependent UI** for core functionality. Use it as a progressive enhancement with a CSS/Canvas 2D fallback.
4. **Use Capacitor plugins** for sharing, notifications, and file operations rather than web APIs -- they work on all native platforms regardless of Lockdown Mode.
5. **Test on a Lockdown Mode device** when shipping features that touch storage, crypto, or media APIs.
+229
View File
@@ -0,0 +1,229 @@
============================================================
LOCKDOWN MODE DETECTOR REPORT
2026-04-06T23:40:58.170Z
============================================================
VERDICT: Lockdown Mode Likely Active
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
Score: 70% (8/12 key APIs blocked)
============================================================
API TEST RESULTS (detailed)
============================================================
------------------------------------------------------------
[BLOCKED] IndexedDB (weight: 3)
Client-side structured storage
Result: Can't find variable: indexedDB
Diagnostics:
uncaught: Can't find variable: indexedDB
------------------------------------------------------------
[BLOCKED] WebAssembly (weight: 2)
Binary instruction execution
Result: WebAssembly is undefined
Diagnostics:
typeof WebAssembly: undefined
WebAssembly global does not exist
------------------------------------------------------------
[BLOCKED] Web Locks API (weight: 3)
Cross-tab resource coordination
Result: navigator.locks is undefined
Diagnostics:
typeof navigator.locks: undefined
'locks' in navigator: false
navigator.locks is falsy
------------------------------------------------------------
[BLOCKED] Speech Synthesis (weight: 3)
Web Speech API (text-to-speech)
Result: window.speechSynthesis is undefined
Diagnostics:
typeof window.speechSynthesis: undefined
'speechSynthesis' in window: false
typeof SpeechSynthesisUtterance: undefined
speechSynthesis is falsy
------------------------------------------------------------
[BLOCKED] FileReader API (weight: 2)
Local file reading interface
Result: FileReader is undefined
Diagnostics:
typeof FileReader: undefined
FileReader constructor does not exist
------------------------------------------------------------
[AVAILABLE] File Constructor (weight: 2)
File object creation
Result: File created: name=test.txt size=4
Diagnostics:
typeof File: function
calling new File(['test'], 'test.txt', {type:'text/plain'})...
succeeded
f.name: test.txt
f.size: 4
f.type: text/plain
f instanceof Blob: true
------------------------------------------------------------
[BLOCKED] WebGL (weight: 2)
GPU-accelerated graphics
Result: all WebGL contexts returned null
Diagnostics:
getContext('webgl2'): null
getContext('webgl'): null
getContext('experimental-webgl'): null
------------------------------------------------------------
[BLOCKED] WebGL2 (weight: 1)
Advanced GPU graphics context
Result: getContext('webgl2') returned null
Diagnostics:
getContext('webgl2'): null
------------------------------------------------------------
[BLOCKED] Service Worker (weight: 1)
Background script registration
Result: navigator.serviceWorker not present
Diagnostics:
'serviceWorker' in navigator: false
typeof navigator.serviceWorker: undefined
------------------------------------------------------------
[BLOCKED] Web Share API (weight: 0)
Native sharing interface
Result: navigator.share is undefined
Diagnostics:
typeof navigator.share: undefined
typeof navigator.canShare: undefined
------------------------------------------------------------
[BLOCKED] Gamepad API (weight: 1)
Game controller input
Result: navigator.getGamepads not present
Diagnostics:
'getGamepads' in navigator: false
------------------------------------------------------------
[BLOCKED] WebRTC (weight: 2)
Real-time peer communication
Result: RTCPeerConnection is undefined
Diagnostics:
typeof RTCPeerConnection: undefined
typeof webkitRTCPeerConnection: undefined
------------------------------------------------------------
[AVAILABLE] FontFace API (weight: 1)
Dynamic font loading
Result: status: loaded
Diagnostics:
typeof FontFace: function
new FontFace() succeeded
ff.status: unloaded
ff.family: test
ff.status after load: loaded
------------------------------------------------------------
[AVAILABLE] Remote Fonts (weight: 2)
Loading fonts from network via data URI
Result: API works, load rejected: A network error occurred.
Diagnostics:
FontFace created with data URI
ff.status before load: unloaded
caught: DOMException: A network error occurred.
------------------------------------------------------------
[AVAILABLE] JIT Compilation (weight: 2)
JavaScript JIT optimization heuristic
Result: 99.0ms for 1M iterations (JIT likely)
Diagnostics:
running 1,000,000 iterations of Math.sqrt*Math.sin...
elapsed: 99.00ms
sum (to prevent dead-code elimination): -681.7597
threshold: <150ms suggests JIT active
verdict: likely JIT
------------------------------------------------------------
[BLOCKED] Notifications API (weight: 1)
Push notification permission
Result: Notification not in window
Diagnostics:
'Notification' in window: false
typeof Notification: undefined
------------------------------------------------------------
[BLOCKED] WebCodecs (weight: 1)
Low-level VideoDecoder API
Result: VideoDecoder is undefined
Diagnostics:
typeof VideoDecoder: undefined
typeof VideoEncoder: undefined
typeof AudioDecoder: function
------------------------------------------------------------
[AVAILABLE] PDF Embed (weight: 2)
Inline PDF rendering via embed/pdfViewerEnabled
Result: pdfViewerEnabled is true
Diagnostics:
navigator.pdfViewerEnabled: true
typeof navigator.pdfViewerEnabled: boolean
created and appended <embed type=application/pdf>
navigator.mimeTypes['application/pdf']: [object MimeType]
------------------------------------------------------------
[BLOCKED] SharedArrayBuffer (weight: 1)
Shared memory between workers
Result: SharedArrayBuffer is undefined
Diagnostics:
typeof SharedArrayBuffer: undefined
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
------------------------------------------------------------
[BLOCKED] Cache API (weight: 1)
Programmatic HTTP cache (CacheStorage)
Result: caches not in window
Diagnostics:
'caches' in window: false
typeof caches: undefined
------------------------------------------------------------
[BLOCKED] OPFS (weight: 2)
Origin Private File System (navigator.storage.getDirectory)
Result: navigator.storage.getDirectory is not a function
Diagnostics:
typeof navigator.storage: object
typeof navigator.storage.getDirectory: undefined
getDirectory method does not exist
============================================================
NAVIGATOR INFO
============================================================
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
platform: iPhone
vendor: Apple Computer, Inc.
language: en-US
languages: en-US
cookieEnabled: true
doNotTrack: null
maxTouchPoints: 5
hardwareConcurrency: 4
deviceMemory: N/A
pdfViewerEnabled: true
webdriver: false
connection: unavailable
mediaDevices: unavailable
storage: available
serviceWorker: unavailable
credentials: available
bluetooth: unavailable
gpu (WebGPU): unavailable
screenWidth: 393
screenHeight: 852
devicePixelRatio: 3
colorDepth: 24
============================================================
END OF REPORT
============================================================
+3 -1
View File
@@ -2,4 +2,6 @@ VITE_SENTRY_DSN="https://********************************@*****************.exam
VITE_PLAUSIBLE_DOMAIN="example.tld"
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
VITE_NOSTR_PUSH_PUBKEY=""
VITE_NOSTR_PUSH_PUBKEY=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
+48 -2
View File
@@ -57,6 +57,22 @@ deploy-nsite:
--use-fallback-relays
--use-fallback-servers
build-web:
stage: build
timeout: 10 minutes
needs: []
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
script:
- npm ci
- npm run build
- cp dist/index.html dist/404.html
artifacts:
paths:
- dist/
build-apk:
stage: build
image: eclipse-temurin:21-jdk
@@ -129,8 +145,9 @@ build-apk:
- npx vite build -l error
- cp dist/index.html dist/404.html
# Sync web assets to Capacitor Android project
# Sync web assets to Capacitor Android project and register local plugins
- npx cap sync android
- node scripts/patch-cap-config.mjs
# Build signed release APK
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
@@ -202,7 +219,7 @@ 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"
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
BLOSSOM_URL: "https://blossom.ditto.pub"
script:
- go install github.com/zapstore/zsp@latest
@@ -218,3 +235,32 @@ publish-zapstore:
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
publish-google-play:
stage: publish
image: ruby:3.3
needs:
- build-apk
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
script:
- gem install fastlane --no-document
# Decode base64-encoded service account JSON to a temp file
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
# Upload the AAB to Google Play production track
- >-
fastlane supply
--aab artifacts/Ditto.aab
--package_name pub.ditto.app
--track production
--json_key /tmp/play-service-account.json
--skip_upload_metadata
--skip_upload_changelogs
--skip_upload_images
--skip_upload_screenshots
--skip_upload_apk
# Clean up
- rm -f /tmp/play-service-account.json
@@ -0,0 +1,68 @@
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
## Related Issue
<!-- Link the GitLab issue. MRs without a linked issue will not be reviewed. -->
Closes #
## What Changed
<!-- 1-3 sentences: what you changed and why. -->
## Live Preview
<!-- REQUIRED for UI changes. Deploy your branch and paste the URL. -->
<!-- Example: npx surge dist your-branch.surge.sh -->
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
## Screenshots
<!-- REQUIRED for UI changes. Show before and after. -->
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
**Before:**
**After:**
## Philosophy Alignment
<!-- Answer this question for your change: -->
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
<!-- and more peaceful to inhabit?" -->
<!-- See: https://about.ditto.pub/philosophy -->
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
## How to Test
<!-- Steps a reviewer can follow to verify this works. -->
1.
2.
3.
## Self-Review Checklist
<!-- Complete ALL items. MRs with unchecked boxes will not be reviewed. -->
<!-- Check a box: replace [ ] with [x] -->
### Process
- [ ] I read `AGENTS.md` before starting
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- [ ] I used plan/research mode before writing code
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
### Self-review
Copy-paste this into your AI tool and fix any findings before submitting:
> Review this diff against the self-review checklist in CONTRIBUTING.md step 8. Read that file first, then check every item. For each finding, state the file, line, and issue.
- [ ] I ran the self-review prompt above and addressed all findings
### Testing
- [ ] I ran `npm run test` locally and it passes
- [ ] I tested the change manually in the browser
+138 -13
View File
@@ -283,16 +283,20 @@ When adding support for a new Nostr event kind to the application, the kind must
3. **Detail page** (`src/pages/PostDetailPage.tsx`):
- Add the same `isMyKind` detection flag and include it in the group/exclusion flags (mirrors NoteCard)
- Add the content dispatch for the detail view
- Add an entry in `shellTitleForKind()` for the loading state title
- `shellTitleForKind()` falls through to the central `KIND_LABELS` registry, so adding a label there is sufficient for the loading state title. Only add a manual override in `shellTitleForKind()` if the kind belongs to a group (e.g. music kinds → "Track Details") or needs a composite label (e.g. "Badge Collection")
- Import the new component
4. **Feed registration** (`src/lib/extraKinds.ts`):
- Add the kind number to an existing feed definition's `extraFeedKinds` array, or create a new `ExtraKindDef` entry
5. **Kind label registries** -- these are separate maps that resolve kind numbers to human-readable strings. All must be updated:
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- used for "Commenting on an nsite" text and inline icons
- `WELL_KNOWN_KIND_LABELS` in `src/components/ExternalContentHeader.tsx` -- used in addressable event preview headers
- The icon fallback in `AddressableEventPreview` in the same file
5. **Central kind label registry** (`src/lib/kindLabels.ts`):
- Add an entry to the `KIND_LABELS` map with a short, user-facing label (capitalized noun phrase, no articles)
- This registry is the single source of truth for kind→label mappings and is consumed by the nsite permission prompt, signer nudge toasts, detail page loading titles, and addressable event preview headers
- Some UI contexts maintain **context-specific** label maps that cannot use the central registry directly (they need different grammar):
- `KIND_LABELS` and `KIND_ICONS` in `src/components/CommentContext.tsx` -- uses articles ("a post", "an article") for "Commenting on {label}" text
- `NOTIFICATION_KIND_NOUNS` in `src/pages/NotificationsPage.tsx` -- uses bare lowercase nouns for notification action text
- `KIND_HEADER_MAP` in `src/components/NoteCard.tsx` -- uses action verbs + nouns for feed headers
- These context-specific maps must also be updated when adding a new kind
6. **Embedded note cards** (`src/components/EmbeddedNote.tsx`, `src/components/EmbeddedNaddr.tsx`) -- these are the small preview cards shown inside quote posts, reply context indicators, and CommentContext hover cards. They are **separate components** from `NoteCard` and render a minimal card (author + title/content preview + attachment indicators). Basic rendering works for all kinds automatically, but kinds whose media lives in tags rather than in the `content` field (e.g. kind 20 photos via `imeta` tags) may need attachment indicator logic added to `EmbeddedNoteCard`.
@@ -303,7 +307,7 @@ When adding support for a new Nostr event kind to the application, the kind must
#### Why so many places?
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. However, several of them maintain independent kind-to-label maps that could theoretically be unified. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
These are genuinely different UI contexts (feed cards, detail pages, embedded note cards, reply previews, comment context labels) with different rendering requirements. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common case, but several contexts need grammar-specific maps (articles, verbs, lowercase nouns) that can't be derived mechanically. When in doubt, search the codebase for an existing kind number like `30617` to find all the registration points.
### NIP.md
@@ -409,6 +413,74 @@ Without filtering approvals by the moderator list, anyone could publish kind 455
Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.).
#### Sanitizing URLs from Event Data
**CRITICAL**: Any URL extracted from Nostr event tags, content, or metadata fields is **untrusted user input**. Malicious URLs can cause harm in many ways beyond `javascript:` XSS — `data:` URIs for resource exhaustion, `http://` URLs leaking user IPs without TLS, relative paths triggering unintended requests to the app's own origin, and more. Reasoning about which rendering context is "safe enough" to skip sanitization is fragile and error-prone.
**Rule: sanitize every event-sourced URL unconditionally**, regardless of where it will be used (`href`, `img src`, `style`, etc.). Use `sanitizeUrl()` from `@/lib/sanitizeUrl`:
```typescript
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// Single URL — returns the normalised href, or undefined if not valid https
const url = sanitizeUrl(getTag(event.tags, 'url'));
if (url) {
// safe to use in any context
}
// Array of URLs — filter out invalid entries
const links = getAllTags(event.tags, 'r')
.map(([, v]) => sanitizeUrl(v))
.filter((v): v is string => !!v);
```
`sanitizeUrl` accepts `string | undefined | null` and returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
**Best practice — sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
**When sanitization is NOT required:**
- URLs extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`)
- Hardcoded or application-generated URLs (relay configs, internal routes, etc.)
- URLs displayed as plain text without being placed into any HTML attribute or CSS value
#### Preventing CSS Injection from Event Data
**CRITICAL**: Any value from a Nostr event that is interpolated into a CSS string (inside a `<style>` element or inline `style` attribute) is a CSS injection vector. A malicious value containing `"`, `)`, `}`, or `;` can break out of the CSS context and inject arbitrary rules — for example, overlaying phishing content or hiding UI elements.
**Common CSS injection surfaces:**
- `background-image: url("${url}")` — a URL with `"); body { display:none }` breaks out
- `font-family: "${family}"` — a family name with `"; } body { visibility:hidden } .x {` breaks out
- `@font-face { src: url("${url}") }` — same risk as background URLs
**Mitigation strategy — sanitize at the parse layer:**
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
```typescript
// ❌ UNSAFE — raw event data interpolated into CSS
const bgUrl = getTagValue(event.tags, 'bg');
style.textContent = `body { background-image: url("${bgUrl}"); }`;
const family = getTagValue(event.tags, 'f');
style.textContent = `html { font-family: "${family}"; }`;
// ✅ SAFE — URLs validated, strings sanitised
import { sanitizeUrl } from '@/lib/sanitizeUrl';
const bgUrl = sanitizeUrl(getTagValue(event.tags, 'bg'));
if (bgUrl) {
style.textContent = `body { background-image: url("${bgUrl}"); }`;
}
// For non-URL strings, allowlist safe characters only
const safeFamily = family.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
style.textContent = `html { font-family: "${safeFamily}"; }`;
```
**Rule of thumb**: Never interpolate untrusted strings into CSS without sanitisation. If it's a URL, use `sanitizeUrl()`. If it's any other string, strip characters that can break out of the CSS string context.
### The `useNostr` Hook
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
@@ -699,23 +771,47 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const freshEvent = await fetchFreshEvent(nostr, {
const prev = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = freshEvent?.tags ?? [];
const currentTags = prev?.tags ?? [];
// ...modify tags...
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
#### The `prev` Property on Event Templates
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
- **Update (`prev` provided)**: `published_at` is preserved from the old event
- **Old event lacks `published_at`**: nothing is fabricated
- **Caller already set `published_at` in tags**: left alone
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
```typescript
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// ...
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
```
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
@@ -1311,6 +1407,10 @@ Run available tools in this priority order:
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
### Contributing Guide
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
### Using Git
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
@@ -1388,7 +1488,7 @@ The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only)
### Creating a Release
@@ -1398,7 +1498,7 @@ Releases are triggered by pushing a version tag. Use the npm script:
npm run release
```
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, and `publish-zapstore` stages.
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` stages.
### Zapstore Publishing
@@ -1490,4 +1590,29 @@ The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyt
To rotate the nsite credential:
1. Revoke the old bunker connection in your signer app
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
### Google Play Publishing
The project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track.
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
#### Initial Setup (one-time)
1. Create or reuse a project in the [Google Cloud Console](https://console.cloud.google.com/projectcreate)
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`
5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
#### Key Points
- The job uploads the signed AAB (not APK) since Google Play requires App Bundles
- Uploads go directly to the **production** track -- Google's review process still applies before the update reaches users
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.)
- The same signing keystore used for Zapstore is used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`)
+139
View File
@@ -1,5 +1,144 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
+184
View File
@@ -0,0 +1,184 @@
# Contributing to Ditto
We welcome contributions, but we have high standards. Ditto is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
**Required reading before you start:**
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
- [Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
## Understanding Ditto
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
### The product decision filter
Every change to Ditto should pass this test:
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
- **Magnetic** -- Ditto attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
- **Threatening to the status quo** -- Ditto threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
- **Peaceful to inhabit** -- Ditto displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
### What Ditto is NOT
- A Twitter/X clone with decentralization bolted on
- A place to replicate features that mainstream platforms already do well
- A showcase for generic UI components or boilerplate social features
### What Ditto IS
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
- A place where profiles feel like worlds, not business cards
- The most fun you've had on the internet in years
Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete vision.
## What we accept
### Bug fixes
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
### New features and significant changes
Every feature MR must link to an existing open issue and clearly align with the [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong.
If you have an idea for a feature that doesn't have an issue yet:
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://about.ditto.pub/contributing)).
2. Prove it works and get user feedback.
3. Open an issue to discuss integration.
**Feature MRs that don't link to an issue or don't align with the Ditto Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
## Required tools
- **Claude Opus 4.6** (or the latest frontier model) -- not Sonnet, not GPT-4o, not local models. Quality depends on model quality.
- **An AI coding agent with plan/research mode** -- [OpenCode](https://opencode.ai), [Shakespeare](https://shakespeare.diy), Cursor, or similar.
- **Node.js 22+** and npm 10.9.4+.
## The contribution workflow
Follow these steps in order. Skipping steps is the most common reason MRs are rejected.
### 1. Ask: does anyone need this?
Before writing a single line of code, answer this honestly. For bug fixes this is straightforward -- someone hit the bug. For features, it requires more thought. Is there evidence of real user demand? Is the underlying technology mature enough? A beautifully written feature for a nonexistent user base is the wrong thing to build. If you can't point to a concrete user need, reconsider.
### 2. Understand the issue
Read the issue thoroughly. If anything is unclear, ask in the issue comments before writing code. Understand not just *what* to change, but *why* -- what problem does this solve for users?
### 3. Read the codebase conventions
Read `AGENTS.md` in the repo root. This is the single source of truth for how code should be written in this project. Your AI tool should load this file automatically. If it doesn't, paste it in or configure your tool to read it.
### 4. Read the philosophy
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
### 5. Plan before you code
Start your AI tool in **plan mode** (or research/think mode). Spend the first few prompts:
- Exploring the existing codebase to understand how similar features are implemented
- Reading the files you'll need to modify
- Proposing an approach
Do not write code until you have a plan. The most expensive mistake is implementing the wrong approach.
### 6. Implement
Switch to code mode and implement your plan. Use Opus 4.6 or equivalent.
### 7. Run the test suite
```sh
npm run test
```
This runs type-checking, linting, unit tests, and a production build. All must pass. Do not submit an MR with a failing test suite.
### 8. Self-review
Run this prompt against your diff (copy the full `git diff` output and paste it to your AI tool along with this prompt):
```
Review this diff as if you are a senior maintainer of this codebase who has to
maintain it long-term. For each finding, state the file, line, and issue.
- [ ] Does the diff contain changes that weren't requested? Flag anything out of scope.
- [ ] Is there dead code, commented-out blocks, or debug artifacts left in?
- [ ] Are there placeholder comments like "// In a real app..." or "// TODO: implement"?
- [ ] For every value displayed to a user, can you trace it from source to render without a gap?
- [ ] Are error, loading, and empty states all handled -- and in the right order?
- [ ] Does a mutation reflect in the UI without requiring a manual refresh?
- [ ] Is there a new read/write path that assumes fresh data but could get a stale cache?
- [ ] For replaceable/addressable Nostr events: is fetchFreshEvent used before mutation?
- [ ] Does anything new block the critical render path or fire N+1 network requests?
- [ ] Are Nostr queries efficient (combined kinds, relay-level filtering vs client-side)?
- [ ] Are user inputs used in queries or rendered as content without sanitization?
- [ ] Were existing patterns/conventions in AGENTS.md ignored in favor of something novel?
- [ ] Are secrets, keys, or env-specific values hardcoded?
- [ ] Does the code use the `any` type anywhere?
- [ ] Is the code Capacitor-compatible (no `<a download>`, no `window.open()`)?
- [ ] Are new Nostr event kinds documented in NIP.md with links to relevant specs?
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
Then answer: "If you were the people who have to maintain this codebase and deal
with all long-term issues, what would be your biggest concerns about this
implementation?"
```
Address every finding before submitting.
### 9. Deploy a live preview
Deploy your branch so reviewers can test it without pulling your code:
```sh
npm run build
npx surge dist your-branch-name.surge.sh
```
Or use Netlify, Vercel, or any static hosting. Include the live preview URL in your MR description.
### 10. Take screenshots
Capture before and after screenshots of any UI changes. Include them directly in the MR description. If your change has no visual component, state that explicitly.
### 11. Submit
Fill out every field in the MR template. Incomplete MRs will not be reviewed.
## What gets your MR closed without review
- No linked issue
- Feature MRs with no clear alignment with the [Ditto Philosophy](https://about.ditto.pub/philosophy)
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
- Incomplete MR template (missing checklist, screenshots, or preview URL)
- Changes that go beyond what was asked for (scope creep)
- Placeholder code, dead code, or debug artifacts
- Evidence of low-quality AI generation ("In a real application..." comments, hallucinated APIs, generic template code)
- Failing test suite
- No evidence of planning (code-first, think-later approach produces recognizable patterns)
- Undocumented Nostr event kinds (new kinds must be in NIP.md)
- Large binary assets committed to git (images >100KB, fonts, videos)
- Security issues (dangerouslySetInnerHTML, eval, innerHTML, unsanitized user input)
## MR review process
1. The CI pipeline validates your MR description automatically. If it fails, read the error message and fix your MR description.
2. Maintainers will review your MR when all CI checks pass and the template is complete.
3. If changes are requested, address them promptly. Stale MRs will be closed.
We appreciate your interest in contributing. These standards exist because reviewing a low-quality MR takes 3x longer than doing the work ourselves. Help us help you by following the process.
+11
View File
@@ -138,6 +138,17 @@ src/
public/ Static assets, icons, manifest
```
## Contributing
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
- **Bug fixes**: One bug, one MR. Keep it small and focused.
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
## License
[AGPL-3.0](LICENSE)
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.4.1"
versionName "2.6.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+3 -1
View File
@@ -11,9 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capgo-capacitor-autofill-save-password')
implementation project(':capacitor-secure-storage-plugin')
}
@@ -1,7 +1,10 @@
package pub.ditto.app;
import android.content.SharedPreferences;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log;
import com.getcapacitor.Plugin;
@@ -14,6 +17,10 @@ import org.json.JSONArray;
/**
* Capacitor plugin that allows the JS layer to configure the native
* notification polling service with the user's pubkey and relay URLs.
*
* Supports two notification styles:
* - "push" (default): no foreground service, relies on push notifications
* - "persistent": starts NotificationRelayService as a foreground service
*/
@CapacitorPlugin(name = "DittoNotification")
public class DittoNotificationPlugin extends Plugin {
@@ -24,6 +31,7 @@ public class DittoNotificationPlugin extends Plugin {
@PluginMethod
public void configure(PluginCall call) {
String userPubkey = call.getString("userPubkey");
String notificationStyle = call.getString("notificationStyle", "push");
String relayUrlsRaw = null;
String enabledKindsRaw = null;
String authorsRaw = null;
@@ -60,7 +68,8 @@ public class DittoNotificationPlugin extends Plugin {
if (userPubkey != null && relayUrlsRaw != null) {
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw);
.putString("relayUrls", relayUrlsRaw)
.putString("notificationStyle", notificationStyle);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
@@ -70,13 +79,46 @@ public class DittoNotificationPlugin extends Plugin {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
} else {
// Clear config (user logged out)
prefs.edit().clear().apply();
Log.d(TAG, "Config cleared (user logged out)");
}
// Start or stop the foreground service based on style
manageService(notificationStyle, userPubkey != null && relayUrlsRaw != null);
call.resolve();
}
/**
* Start the foreground service when style is "persistent" and config is valid.
* Stop it otherwise.
*/
private void manageService(String style, boolean hasConfig) {
Context ctx = getContext();
Intent serviceIntent = new Intent(ctx, NotificationRelayService.class);
if ("persistent".equals(style) && hasConfig) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(serviceIntent);
} else {
ctx.startService(serviceIntent);
}
Log.d(TAG, "Started NotificationRelayService (persistent mode)");
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w(TAG, "Could not start foreground service: " + e.getMessage());
} else {
Log.w(TAG, "Failed to start service", e);
}
}
} else {
ctx.stopService(serviceIntent);
Log.d(TAG, "Stopped NotificationRelayService (push mode or no config)");
}
}
}
@@ -1,7 +1,9 @@
package pub.ditto.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -11,32 +13,36 @@ import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final String PREFS_NAME = "ditto_notification_config";
@Override
protected void onCreate(Bundle savedInstanceState) {
// Register the native notification config plugin before super.onCreate
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(SandboxPlugin.class);
super.onCreate(savedInstanceState);
// Start the persistent relay connection service.
// On Android 12+ (API 31+) the system may throw
// ForegroundServiceStartNotAllowedException if the foreground service
// time limit for this type has already been exhausted. We catch it so
// the app continues to run normally; the alarm inside the service will
// retry at the next scheduled interval.
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
// Only start the foreground service if the user has opted into
// "persistent" notification style. Default is "push" (no service).
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String style = prefs.getString("notificationStyle", "push");
if ("persistent".equals(style)) {
try {
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent);
} else {
startService(serviceIntent);
}
} catch (Exception e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e instanceof ForegroundServiceStartNotAllowedException) {
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
} else {
throw e;
}
}
}
@@ -0,0 +1,222 @@
package pub.ditto.app;
import android.util.Base64;
import android.util.Log;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Capacitor plugin that intercepts requests from sandbox iframes in the
* main Capacitor WebView.
*
* On Android, each sandbox iframe loads from
* {@code https://<sandbox-id>.sandbox.native/path}. A custom
* {@link BridgeWebViewClient} subclass intercepts these requests via
* {@code shouldInterceptRequest}, forwards them to the JS layer as "fetch"
* events, and blocks the WebView IO thread until JS responds with
* {@code respondToFetch()}.
*
* Each unique hostname is a different web origin, so localStorage / IndexedDB
* are fully isolated per sandbox — no separate WebView instances needed.
*/
@CapacitorPlugin(name = "SandboxPlugin")
public class SandboxPlugin extends Plugin {
private static final String TAG = "SandboxPlugin";
/** Pending requests waiting for JS to respond. */
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
@Override
public void load() {
// Replace the main WebView's client with our subclass that intercepts
// sandbox iframe requests.
Bridge bridge = getBridge();
bridge.setWebViewClient(new SandboxBridgeWebViewClient(bridge, this));
}
@PluginMethod
public void respondToFetch(PluginCall call) {
String requestId = call.getString("requestId");
if (requestId == null) {
call.reject("Missing required parameter: requestId");
return;
}
JSObject response = call.getObject("response");
if (response == null) {
call.reject("Missing required parameter: response");
return;
}
int status = response.optInt("status", 200);
String statusText = response.optString("statusText", "OK");
String bodyBase64 = response.optString("body", null);
Map<String, String> headers = new HashMap<>();
JSONObject headersObj = response.optJSONObject("headers");
if (headersObj != null) {
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
String key = it.next();
headers.put(key, headersObj.optString(key));
}
}
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) {
call.resolve();
return;
}
byte[] bodyBytes = null;
if (bodyBase64 != null && !bodyBase64.equals("null")) {
try {
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
} catch (Exception e) {
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
}
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse webResponse = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(webResponse);
call.resolve();
}
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("requestId", requestId);
data.put("request", request);
notifyListeners("fetch", data);
}
// -------------------------------------------------------------------------
// Custom BridgeWebViewClient that intercepts sandbox iframe requests
// -------------------------------------------------------------------------
/**
* Extends Capacitor's BridgeWebViewClient to additionally intercept
* requests from sandbox iframes (URLs matching *.sandbox.native).
*/
private static class SandboxBridgeWebViewClient extends BridgeWebViewClient {
private final SandboxPlugin plugin;
SandboxBridgeWebViewClient(Bridge bridge, SandboxPlugin plugin) {
super(bridge);
this.plugin = plugin;
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String host = request.getUrl().getHost();
// Intercept requests to *.sandbox.native (from sandbox iframes).
if (host != null && host.endsWith(".sandbox.native")) {
return handleSandboxRequest(request, host);
}
// Everything else: delegate to Capacitor's default handling.
return super.shouldInterceptRequest(view, request);
}
private WebResourceResponse handleSandboxRequest(WebResourceRequest request, String host) {
// Extract sandbox ID from the hostname (e.g. "abc123.sandbox.native" -> "abc123").
String sandboxId = host.replace(".sandbox.native", "");
String requestId = UUID.randomUUID().toString();
PendingRequest pending = new PendingRequest();
plugin.pendingRequests.put(requestId, pending);
// Serialise the request for the JS layer.
String path = request.getUrl().getPath();
if (path == null || path.isEmpty()) path = "/";
String rewrittenURL = "https://" + sandboxId + ".sandbox.native" + path;
JSObject serialisedRequest = new JSObject();
serialisedRequest.put("url", rewrittenURL);
serialisedRequest.put("method", request.getMethod());
JSObject headers = new JSObject();
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
headers.put(entry.getKey(), entry.getValue());
}
serialisedRequest.put("headers", headers);
serialisedRequest.put("body", JSONObject.NULL);
// Emit to JS.
plugin.emitFetchRequest(sandboxId, requestId, serialisedRequest);
// Block until JS responds. The WebView IO thread pool has ~6
// threads; pre-fetching blobs in JS before setting the iframe src
// ensures this blocking time is minimal (cache hits).
WebResourceResponse response = pending.awaitResponse(60000);
if (response != null) {
return response;
}
// Timeout — return error response.
plugin.pendingRequests.remove(requestId);
return new WebResourceResponse(
"text/plain", "UTF-8", 504,
"Gateway Timeout", new HashMap<>(),
new ByteArrayInputStream("Request timed out".getBytes())
);
}
}
// -------------------------------------------------------------------------
// Pending request helper
// -------------------------------------------------------------------------
/**
* A pending request that blocks the WebView IO thread until JS responds.
*/
private static class PendingRequest {
private volatile WebResourceResponse response;
private final CountDownLatch latch = new CountDownLatch(1);
void resolve(WebResourceResponse response) {
this.response = response;
latch.countDown();
}
WebResourceResponse awaitResponse(long timeoutMs) {
try {
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return response;
}
}
}
+8 -2
View File
@@ -8,11 +8,17 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capgo-capacitor-autofill-save-password'
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
include ':capacitor-secure-storage-plugin'
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
+9 -4
View File
@@ -5,8 +5,6 @@ const config: CapacitorConfig = {
appName: 'Ditto',
webDir: 'dist',
server: {
// Handle deep links from your domain
hostname: 'ditto.pub',
androidScheme: 'https',
iosScheme: 'https'
},
@@ -17,9 +15,16 @@ const config: CapacitorConfig = {
},
ios: {
backgroundColor: '#14161f',
contentInset: 'automatic',
contentInset: 'never',
scheme: 'Ditto'
}
},
plugins: {
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
insetsHandling: 'css',
},
},
};
export default config;
+1 -1
View File
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
import customRules from "./eslint-rules/index.js";
export default tseslint.config(
{ ignores: ["dist", "android"] },
{ ignores: ["dist", "android", "ios"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<title>Ditto — Your content. Your vibe. Your rules.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
<!-- Open Graph -->
+20 -2
View File
@@ -15,6 +15,9 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -28,6 +31,10 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -63,11 +70,15 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -145,6 +156,7 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -156,6 +168,8 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -295,15 +309,17 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GZLTTH5DLM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.4.1;
MARKETING_VERSION = 2.6.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -317,15 +333,17 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GZLTTH5DLM;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.4.1;
MARKETING_VERSION = 2.6.6;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:ditto.pub</string>
<string>webcredentials:ditto.pub?mode=developer</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
@@ -0,0 +1,24 @@
import UIKit
import WebKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
let config = super.webViewConfiguration(for: instanceConfiguration)
// Register the sbx:// custom scheme handler BEFORE the WKWebView is
// created. Each sandbox iframe loads from sbx://<sandbox-id>/path,
// giving every sandbox a unique web origin with full storage isolation.
let handler = SandboxRequestHandler()
_sandboxHandler = handler
config.setURLSchemeHandler(handler, forURLScheme: "sbx")
return config
}
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
}
}
+4
View File
@@ -49,7 +49,11 @@
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
<key>NSCameraUsageDescription</key>
<string>Ditto needs camera access to take photos and videos for your posts.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Ditto needs access to your microphone to record voice messages.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>
+230
View File
@@ -0,0 +1,230 @@
import Foundation
import Capacitor
import WebKit
// MARK: - Shared Handler Singleton
/// The sandbox request handler singleton.
/// Created by `DittoBridgeViewController` at WKWebView configuration time,
/// then connected to the `SandboxPlugin` when the plugin loads.
var _sandboxHandler: SandboxRequestHandler?
// MARK: - Sandbox Scheme Handler
/// `WKURLSchemeHandler` for the `sbx://` custom scheme.
///
/// Each sandbox iframe loads from `sbx://<sandbox-id>/path`, giving every
/// sandbox a unique web origin with full localStorage / IndexedDB / cookie
/// isolation.
///
/// Intercepted requests are forwarded to the JS layer via the Capacitor
/// plugin bridge. JS resolves the file and responds with `respondToFetch()`.
class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
private var pendingTasks: [String: WKURLSchemeTask] = [:]
private let lock = NSLock()
weak var plugin: SandboxPlugin?
/// Diagnostics: total number of start calls received.
var startCallCount: Int = 0
/// Diagnostics: last URL received by the handler.
var lastURL: String = "(none)"
/// Number of pending tasks (for diagnostics).
var pendingTaskCount: Int {
lock.lock()
let count = pendingTasks.count
lock.unlock()
return count
}
// MARK: WKURLSchemeHandler
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
startCallCount += 1
lastURL = urlSchemeTask.request.url?.absoluteString ?? "(nil)"
let request = urlSchemeTask.request
guard let url = request.url else {
urlSchemeTask.didFailWithError(NSError(
domain: "SandboxPlugin", code: -1,
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
))
return
}
let requestId = UUID().uuidString
lock.lock()
pendingTasks[requestId] = urlSchemeTask
lock.unlock()
// Extract the sandbox ID from the hostname: sbx://<sandbox-id>/path
let sandboxId = url.host ?? "unknown"
var headers: [String: String] = [:]
if let allHeaders = request.allHTTPHeaderFields {
headers = allHeaders
}
var bodyBase64: String? = nil
if let bodyData = request.httpBody {
bodyBase64 = bodyData.base64EncodedString()
}
let path = url.path.isEmpty ? "/" : url.path
// Rewrite URL so JS sees a consistent format matching Android.
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
let serialisedRequest: [String: Any] = [
"url": rewrittenURL,
"method": request.httpMethod ?? "GET",
"headers": headers,
"body": bodyBase64 as Any,
]
plugin?.emitFetchRequest(
sandboxId: sandboxId,
requestId: requestId,
request: serialisedRequest
)
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
lock.lock()
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
if let key = removed?.key {
pendingTasks.removeValue(forKey: key)
}
lock.unlock()
}
// MARK: Response Resolution
func resolveRequest(
requestId: String,
status: Int,
statusText: String,
headers: [String: String],
bodyBase64: String?
) {
lock.lock()
guard let task = pendingTasks.removeValue(forKey: requestId) else {
lock.unlock()
return
}
lock.unlock()
var bodyData: Data? = nil
if let b64 = bodyBase64 {
bodyData = Data(base64Encoded: b64)
}
let responseURL = task.request.url ?? URL(string: "sbx://unknown/")!
let response = HTTPURLResponse(
url: responseURL,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
DispatchQueue.main.async {
task.didReceive(response)
if let data = bodyData {
task.didReceive(data)
}
task.didFinish()
}
}
func cancelAll() {
lock.lock()
let tasks = pendingTasks
pendingTasks.removeAll()
lock.unlock()
for (_, task) in tasks {
task.didFailWithError(NSError(
domain: "SandboxPlugin", code: -999,
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
))
}
}
}
// MARK: - Plugin
/// Capacitor plugin that bridges sandbox fetch events between native and JS.
///
/// On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered
/// on the WKWebView configuration before the web view is created. Each
/// sandbox loads from `sbx://<sandbox-id>/path`, providing full origin
/// isolation (separate localStorage, cookies, etc.).
///
/// On Android, a custom `BridgeWebViewClient` subclass intercepts requests
/// to `https://<sandbox-id>.sandbox.native/path`.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "diagnose", returnType: CAPPluginReturnPromise),
]
public override func load() {
// Connect the shared handler to this plugin so it can emit events.
_sandboxHandler?.plugin = self
}
@objc func respondToFetch(_ call: CAPPluginCall) {
guard let requestId = call.getString("requestId") else {
call.reject("Missing required parameter: requestId")
return
}
guard let response = call.getObject("response") else {
call.reject("Missing required parameter: response")
return
}
guard let handler = _sandboxHandler else {
call.reject("Sandbox handler not initialised")
return
}
handler.resolveRequest(
requestId: requestId,
status: response["status"] as? Int ?? 200,
statusText: response["statusText"] as? String ?? "OK",
headers: response["headers"] as? [String: String] ?? [:],
bodyBase64: response["body"] as? String
)
call.resolve()
}
/// Diagnostic method callable from JS to inspect native state.
@objc func diagnose(_ call: CAPPluginCall) {
let handler = _sandboxHandler
call.resolve([
"sandboxHandlerSet": handler != nil,
"pluginConnected": handler?.plugin != nil,
"bridgeHasWebView": bridge?.webView != nil,
"hasListenersFetch": hasListeners("fetch"),
"pendingTaskCount": handler?.pendingTaskCount ?? 0,
"startCallCount": handler?.startCallCount ?? 0,
"lastURL": handler?.lastURL ?? "(no handler)",
])
}
// MARK: - Event Forwarding
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
}
+6 -2
View File
@@ -14,9 +14,11 @@ let package = Package(
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
],
targets: [
.target(
@@ -26,9 +28,11 @@ let package = Package(
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
]
)
]
+392 -245
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -1,12 +1,13 @@
{
"name": "ditto",
"private": true,
"version": "2.4.1",
"version": "2.6.6",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
"icons": "bash scripts/generate-icons.sh"
},
@@ -17,9 +18,10 @@
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
"@capgo/capacitor-autofill-save-password": "^8.0.22",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -64,8 +66,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.5.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -97,10 +99,11 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -0,0 +1,7 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.ditto.app"
]
}
}
+139
View File
@@ -1,5 +1,144 @@
# Changelog
## [2.6.6] - 2026-04-12
### Fixed
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
- Emoji shortcodes now render as color emoji instead of plain text glyphs
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
- Signing requests on Android are more reliable and no longer silently fail after switching apps
## [2.6.5] - 2026-04-11
### Changed
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
### Fixed
- External API requests on Android no longer fail due to hostname restrictions
- iOS App Store compliance issues resolved
## [2.6.4] - 2026-04-11
### Added
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
### Changed
- Empty feeds show a friendlier state with a discover button to help you find people to follow
- Signup flow simplified -- cleaner profile step with a single Continue button
### Fixed
- Avatar fallback now shows the user's initial instead of a question mark
- Android 16+ devices no longer have content hidden behind system bars
- Signup dialog background clears properly when switching between light and dark themes
- Sticky compose button stays anchored to the bottom even on empty feeds
## [2.6.3] - 2026-04-10
### Added
- Lightning invoices embedded in posts now render as tappable payment cards
- Blobbi companions in the feed reflect their current condition and projected health
### Changed
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
- "Request to Vanish" renamed to "Delete Account" for clarity
### Fixed
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
- Security hardening for URLs and styles sourced from the network
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
- Manage your interest tabs (hashtags and locations) from the settings page
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
- Follow packs and follow sets now show author info and action headers in the feed
- Posts now show whether they were created or updated, so you can tell when something's been edited
### Changed
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
- Nsite previews now use the same secure sandbox as webxdc apps
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
### Fixed
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
- Mobile compose box no longer randomly collapses or becomes unclickable
- Profile avatar and banner lightbox no longer hides behind the right sidebar
- Infinite scroll on custom profile tab feeds no longer reloads the same content
- Reaction emoji are now visible on each row in the interactions modal
- Missing bottom border on collapsed thread expand button restored
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
- Poll votes now appear as activity cards in feeds and on detail pages
### Fixed
- Threads and replies load more reliably by following relay and author hints when fetching parent events
## [2.5.1] - 2026-04-03
### Fixed
- Lightbox now reliably appears above all content, not just when opened from photo galleries
## [2.5.0] - 2026-04-03
### Added
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
- File uploads in the poll composer -- attach images and media to your polls
- Blobbi posts now appear in the homepage feed
### Changed
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
- App cards now show banner images and improved layout
### Fixed
- Lightbox no longer appears behind the right sidebar
- Compose box corners are properly rounded
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
## [2.4.1] - 2026-04-02
### Added
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
*
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
* only, so local plugins compiled directly into the app binary (like
* SandboxPlugin) are not included. This script appends them after sync so
* the Capacitor bridge eagerly registers them at startup.
*
* Usage: node scripts/patch-cap-config.mjs
* Typically run after `npx cap sync`.
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
/** Local plugin class names to ensure are registered. */
const LOCAL_PLUGINS = ['SandboxPlugin'];
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
for (const platform of platforms) {
const configPath = resolve(platform, 'capacitor.config.json');
let config;
try {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch {
// Platform may not exist or config not yet generated — skip.
continue;
}
const classList = new Set(config.packageClassList ?? []);
let changed = false;
for (const plugin of LOCAL_PLUGINS) {
if (!classList.has(plugin)) {
classList.add(plugin);
changed = true;
}
}
if (changed) {
config.packageClassList = [...classList];
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
}
}
+16 -13
View File
@@ -1,8 +1,7 @@
// NOTE: This file should normally not be modified unless you are adding a new provider.
// To add new routes, edit the AppRouter.tsx file.
import { Capacitor } from "@capacitor/core";
import { StatusBar, Style } from "@capacitor/status-bar";
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
import { NostrLoginProvider } from "@nostrify/react/login";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
@@ -24,6 +23,7 @@ import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -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",
],
@@ -148,6 +149,8 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
};
/**
@@ -180,13 +183,13 @@ export function App() {
useNsecPasteGuard();
useEffect(() => {
// Initialize StatusBar for mobile apps
// Initialize system bars for mobile apps.
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
// setOverlaysWebView / setBackgroundColor no longer work. The new
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
if (Capacitor.isNativePlatform()) {
StatusBar.setStyle({ style: Style.Dark }).catch(() => {
// StatusBar may not be available on all platforms
});
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {
// Ignore errors on unsupported platforms
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
// SystemBars may not be available on all platforms
});
}
}, []);
@@ -197,7 +200,7 @@ export function App() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login">
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<NostrSync />
<NativeNotifications />
+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,16 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
@@ -37,8 +34,8 @@ interface BlobbiActionInventoryModalProps {
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
@@ -49,24 +46,19 @@ export function BlobbiActionInventoryModal({
onOpenChange,
action,
companion,
profile,
profile: _profile,
onUseItem,
onOpenShop,
onOpenShop: _onOpenShop,
isUsingItem,
usingItemId,
}: BlobbiActionInventoryModalProps) {
const actionMeta = ACTION_METADATA[action];
// State for confirmation dialog
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Filter inventory by action type, respecting egg-compatible effects
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
const availableItems = useMemo(() => {
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
@@ -74,46 +66,9 @@ export function BlobbiActionInventoryModal({
const isEmpty = availableItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
const handleUseItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowConfirmDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
// Reset after starting use
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseConfirmDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowConfirmDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const handleOpenShop = () => {
onOpenChange(false);
onOpenShop();
};
// Quantity controls
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
onUseItem(item.itemId);
};
return (
@@ -161,14 +116,10 @@ export function BlobbiActionInventoryModal({
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<span className="text-3xl">{actionMeta.icon}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No Items</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-4">
You don't have any items for this action. Visit the shop to get some!
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for this action at your Blobbi's current stage.
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
@@ -181,7 +132,7 @@ export function BlobbiActionInventoryModal({
item={item}
companion={companion}
action={action}
onUse={() => handleSelectItem(item)}
onUse={() => handleUseItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
@@ -190,24 +141,6 @@ export function BlobbiActionInventoryModal({
)}
</div>
</DialogContent>
{/* Confirmation Dialog with Quantity Selector */}
{selectedItem && (
<BlobbiUseItemConfirmDialog
open={showConfirmDialog}
onOpenChange={handleCloseConfirmDialog}
item={selectedItem}
companion={companion}
action={action}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
)}
</Dialog>
);
}
@@ -238,15 +171,12 @@ function BlobbiInventoryUseRow({
// Preview stat changes - handle egg-specific preview for medicine and clean
const { normalStatChanges, eggStatChanges } = useMemo(() => {
if (isEgg && isMedicine) {
// For eggs using medicine, show health preview
// Eggs use the 3-stat model: health, hygiene, happiness
return {
normalStatChanges: [],
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
};
}
if (isEgg && isClean) {
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
return {
normalStatChanges: [],
eggStatChanges: previewCleanForEgg(
@@ -255,7 +185,6 @@ function BlobbiInventoryUseRow({
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
@@ -280,16 +209,12 @@ function BlobbiInventoryUseRow({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs shrink-0">
x{item.quantity}
</Badge>
</div>
{/* Effect Preview - shown inline on desktop */}
<div className="hidden sm:block">
{hasChanges && (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -308,7 +233,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -350,7 +274,6 @@ function BlobbiInventoryUseRow({
{/* Effect Preview - shown below on mobile */}
{hasChanges && (
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
{/* Normal stat changes */}
{normalStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -369,7 +292,6 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -393,222 +315,3 @@ function BlobbiInventoryUseRow({
</div>
);
}
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
interface BlobbiUseItemConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
action: InventoryAction;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function BlobbiUseItemConfirmDialog({
open,
onOpenChange,
item,
companion,
action,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: BlobbiUseItemConfirmDialogProps) {
const actionMeta = ACTION_METADATA[action];
const isEgg = companion.stage === 'egg';
const isMedicine = action === 'medicine';
const isClean = action === 'clean';
// Preview stat changes for the selected quantity
const statPreview = useMemo(() => {
if (!item.effect) return { normalChanges: [], eggChanges: [] };
if (isEgg && isMedicine) {
// Calculate health change for N items
const healthDelta = item.effect.health ?? 0;
let currentHealth = companion.stats.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
}
const totalDelta = currentHealth - (companion.stats.health ?? 0);
return {
normalChanges: [],
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
};
}
if (isEgg && isClean) {
// Calculate hygiene and happiness changes for N items
const hygieneDelta = item.effect.hygiene ?? 0;
const happinessDelta = item.effect.happiness ?? 0;
let currentHygiene = companion.stats.hygiene ?? 0;
let currentHappiness = companion.stats.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
}
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
return { normalChanges: [], eggChanges: changes };
}
// Normal stats preview - simulate N applications
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const changes: Array<{ stat: string; delta: number }> = [];
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
changes.push({ stat, delta });
}
}
return { normalChanges: changes, eggChanges: [] };
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>{actionMeta.label}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{hasChanges && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<div className="flex flex-wrap gap-2">
{statPreview.normalChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
{statPreview.eggChanges.map(({ stat, delta }) => (
<Badge
key={stat}
variant="secondary"
className={cn(
'text-xs',
delta > 0
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
)}
>
{delta > 0 ? '+' : ''}{delta} {stat}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -98,13 +98,6 @@ export function InlineSingCard({
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
@@ -138,6 +131,13 @@ export function InlineSingCard({
}
}, [audioUrl, cleanupPlayback]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, [cleanupAll]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
+16 -16
View File
@@ -82,22 +82,6 @@ export function SingModal({
// Track the actual MIME type used by the recorder
const actualMimeTypeRef = useRef<string | undefined>(undefined);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, []);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open]);
const cleanup = useCallback(() => {
// Stop timer
if (timerRef.current) {
@@ -142,6 +126,22 @@ export function SingModal({
// Keep lyrics when re-recording so user can sing the same song
}, [cleanup]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// Reset state when modal opens
useEffect(() => {
if (open) {
resetRecording();
} else {
cleanup();
}
}, [open, cleanup, resetRecording]);
// Handle getting random lyrics
const handleRandomLyrics = useCallback(() => {
const lyrics = getRandomLyrics();
@@ -168,7 +168,7 @@ export function useActiveTaskProcess(
}, [processType, hatchTasks, evolveTasks]);
// Extract tasks and state from active result
const tasks = activeResult?.tasks ?? [];
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
@@ -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,15 +69,11 @@ 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;
}
/**
* Hook to execute a direct action on a Blobbi companion.
* Direct actions (play_music, sing) don't consume inventory items.
* Direct actions (play_music, sing) don't require selecting an item.
* They directly affect happiness stat.
*
* This hook:
@@ -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',
@@ -6,19 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
getStageRestrictionMessage,
clampStat,
@@ -37,23 +33,19 @@ import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an inventory item
* Request payload for using an item on a Blobbi companion
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an inventory item
* Result of using an item on a Blobbi companion
*/
export interface UseItemResult {
itemName: string;
action: InventoryAction;
quantity: number;
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
statsChanged: Record<string, number>;
xpGained: number;
newXP: number;
@@ -71,50 +63,44 @@ export interface UseBlobbiUseInventoryItemParams {
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration (use instead of profile.allTags) */
/** Latest profile tags after migration */
profileAllTags: string[][];
/** Latest profile storage after migration (use instead of profile.storage) */
/** Latest profile storage after migration */
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
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
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook to use an inventory item on a Blobbi companion.
* Hook to use an item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog — no
* inventory ownership or quantity is required.
*
* This hook:
* 1. Validates the companion stage (eggs can't use items)
* 2. Validates the item exists in storage
* 3. Ensures canonical format before action
* 4. Applies item effects to Blobbi stats
* 5. Updates Blobbi state (kind 31124)
* 6. Decrements item from profile storage (kind 11125)
* 7. Invalidates relevant queries
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
updateProfileEvent: _updateProfileEvent,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -128,11 +114,6 @@ export function useBlobbiUseInventoryItem({
throw new Error('Profile not found');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions for this specific action
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -145,15 +126,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');
@@ -216,78 +188,25 @@ export function useBlobbiUseInventoryItem({
}
}
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
// ─── Apply Item Effects (single use) ───
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
// Eggs use the 3-stat model: health, hygiene, happiness
// Medicine with health effect directly affects the egg's health stat
// hunger and energy remain fixed at 100 for eggs
const healthDelta = shopItem.effect.health ?? 0;
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
statsUpdate.health = currentHealth.toString();
// Track total actual change (may be less than healthDelta * quantity due to clamping)
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
// Apply decayed values for other egg stats
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
// Egg clean/hygiene handling:
// Hygiene items affect the egg's hygiene stat
// Some hygiene items also give happiness (e.g., bubble bath)
// hunger and energy remain fixed at 100 for eggs
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
// Apply effects N times in sequence
// Only count items that INCREASED hygiene or happiness (positive effects only)
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHygiene = currentHygiene;
const prevHappiness = currentHappiness;
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
// Count as effective if hygiene OR happiness increased (positive effects only)
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
if (hygieneIncreased || happinessIncreased) {
effectiveItemCount++;
}
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -298,58 +217,12 @@ export function useBlobbiUseInventoryItem({
statsChanged.happiness = totalHappinessChange;
}
// Apply decayed health
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
// hunger and energy stay at 100 for eggs
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
// Apply item effects N times in sequence ON TOP of decayed stats
// Use action-aware effectiveness checking for XP calculation
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
const effect = shopItem.effect;
for (let i = 0; i < quantity; i++) {
const prevStats = { ...currentStats };
currentStats = applyItemEffects(currentStats, effect);
// Action-aware effectiveness check:
// Only count INTENDED positive effects, not negative side effects
let isEffective = false;
if (action === 'feed') {
// Feed: count when hunger/energy/health/happiness INCREASE
// Do NOT count hygiene decrease (that's a side effect)
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
} else if (action === 'clean') {
// Clean: count when hygiene or happiness INCREASES
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = hygieneIncreased || happinessIncreased;
} else if (action === 'medicine') {
// Medicine: count when health/energy/happiness INCREASE
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
isEffective = healthIncreased || energyIncreased || happinessIncreased;
} else if (action === 'play') {
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
// Playing naturally consumes energy, so energy decrease counts as valid
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
isEffective = happinessIncreased || energyDecreased;
}
if (isEffective) {
effectiveItemCount++;
}
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -382,11 +255,8 @@ export function useBlobbiUseInventoryItem({
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
@@ -406,46 +276,25 @@ 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]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
invalidateProfile();
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, quantity, xpGained }) => {
onSuccess: ({ itemName, action, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
description: `Used ${itemName} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
-3
View File
@@ -55,9 +55,6 @@ export {
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
buildHatchPhrase,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
+22 -24
View File
@@ -2,23 +2,23 @@
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Actions that consume inventory items
* Item-based care actions (use a shop catalog item on the companion)
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (inventory + direct)
* All Blobbi actions (item-based + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
};
/**
* Action metadata for UI display (inventory actions)
* Action metadata for UI display (item-based care actions)
*/
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
feed: {
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
};
/**
* Action metadata for direct actions (non-inventory)
* Action metadata for direct actions (no item required)
*/
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
play_music: {
@@ -270,10 +270,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
return effects.happiness !== undefined && effects.happiness !== 0;
}
// ─── Inventory Helpers ────────────────────────────────────────────────────────
// ─── Item Helpers ─────────────────────────────────────────────────────────────
/**
* Resolved inventory item with shop metadata
* Resolved catalog item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
@@ -285,7 +285,7 @@ export interface ResolvedInventoryItem {
}
/**
* Options for filtering inventory by action
* Options for filtering catalog items by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
}
/**
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools — no inventory ownership is required.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
* - clean action: only items with hygiene or happiness effect
*/
export function filterInventoryByAction(
storage: StorageItem[],
_storage: StorageItem[],
action: InventoryAction,
options: FilterInventoryOptions = {}
): ResolvedInventoryItem[] {
const allowedType = ACTION_TO_ITEM_TYPE[action];
const result: ResolvedInventoryItem[] = [];
const isEgg = options.stage === 'egg';
const allItems = getLiveShopItems();
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
}
result.push({
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: shopItem.id,
quantity: Infinity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
@@ -376,7 +374,7 @@ export function decrementStorageItem(
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general inventory items (food, toys, hygiene)
* Stages that can use general items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
@@ -409,14 +407,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
/**
* Check if a companion can use a specific inventory action.
* Check if a companion can use a specific item action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all inventory actions at the domain layer.
// All stages can technically use all item actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
@@ -442,7 +440,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
}
/**
* Check if a companion can use general inventory items (feed, play, clean).
* Check if a companion can use general items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
+9 -11
View File
@@ -7,8 +7,8 @@
* Design Philosophy:
* - Different actions award different XP to reflect their complexity/value
* - XP values are balanced to encourage variety in care activities
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - Item actions (feed, play, clean, medicine) give varied XP per action type
* - Direct actions (sing, play_music) give moderate XP
* - XP accumulates across all life stages and never resets
*/
@@ -17,19 +17,18 @@ import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-actio
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
* Base XP values for item-based care actions (feed, play, clean, medicine).
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is costly and critical - highest inventory XP
medicine: 10, // Medicine is critical - highest item XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't consume items - they're free activities.
* These actions don't require selecting an item.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
@@ -58,11 +57,10 @@ export function calculateActionXP(action: BlobbiAction): number {
}
/**
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
* Calculate XP gain for an item-based care action.
*
* @param action - The action performed
* @param quantity - Number of items used (defaults to 1)
* @param quantity - Number of times performed (always 1 in current usage)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
@@ -88,8 +86,8 @@ export function applyXPGain(currentXP: number | undefined, xpGain: number): numb
* Get XP gain summary for displaying to the user.
*
* @param action - The action performed
* @param quantity - Number of times the action was performed (for inventory actions)
* @returns Object with xpGained and total quantity
* @param quantity - Number of times the action was performed (always 1 in current usage)
* @returns Object with xpGained and quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
}
try {
const result = await contextUseItem(item.id, action, 1);
const result = await contextUseItem(item.id, action);
if (result.success) {
if (import.meta.env.DEV) {
@@ -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 { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
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
@@ -4,11 +4,9 @@
* Fetches the current companion data from the user's Blobbonaut profile.
* This is the data layer - it handles fetching and provides companion data.
*
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
* useBlobbisCollection. This ensures:
* - Immediate reactivity when stats change (optimistic updates)
* - Projected decay is applied for accurate visual reactions
* - No duplicate queries or stale cache issues
* Uses useBlobbisCollection with a targeted dList (single d-tag) for efficiency.
* Optimistic updates from mutations propagate across all blobbi-collection
* queries (including BlobbiPage's 'all' mode) via updateCompanionEvent.
*/
import { useMemo } from 'react';
@@ -32,16 +30,14 @@ interface UseBlobbiCompanionDataResult {
*
* Flow:
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
* 2. Build a dList containing just the currentCompanion
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
* 2. Build a dList containing just the currentCompanion (targeted fetch)
* 3. Use useBlobbisCollection with the dList to get the companion
* 4. Apply projected decay for accurate UI reactions
* 5. Return the companion data with projected stats
*
* Reactivity:
* - Uses the same query cache as BlobbiPage (blobbi-collection)
* - When Blobbi state is updated, optimistic updates flow through immediately
* - Projected decay recalculates every 60 seconds
* - No separate query or stale cache issues
* - Optimistic updates propagate across all blobbi-collection queries
* - Projected decay recalculates every 60 seconds while mounted
*/
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
// Use the shared profile hook - this ensures reactivity when profile changes
@@ -80,9 +76,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;
@@ -102,7 +102,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection('right');
setTargetX(targetX);
}, [bounds.maxX]);
}, [bounds.maxX, motionRef]);
/**
* Generate a random observation target on screen.
@@ -136,7 +136,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection(newDirection);
setTargetX(targetXPos);
}, [bounds, generateObservationTarget]);
}, [bounds, generateObservationTarget, motionRef]);
// Make a decision about what to do next
const makeDecision = useCallback(() => {
@@ -176,7 +176,7 @@ export function useBlobbiCompanionState({
// Schedule next decision
const duration = transition.duration ?? randomDuration(config.idleTime);
timerRef.current = window.setTimeout(makeDecision, duration);
}, [isActive, isSleeping, bounds, state, config, startObservation]);
}, [isActive, isSleeping, bounds, state, config, startObservation, motionRef]);
// Handle reaching target
const onReachedTarget = useCallback(() => {
@@ -255,7 +255,7 @@ export function useBlobbiCompanionState({
clearTimeout(timerRef.current);
}
};
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
// Pause decisions while dragging
// We poll isDragging via interval since motionRef changes don't trigger re-renders
@@ -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.
@@ -15,17 +15,15 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
import { useBlobbiItemUse } from './useBlobbiItemUse';
import {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
type BlobbiActionsContextInternal,
} from './BlobbiActionsProvider';
} from './BlobbiActionsContextDef';
// Re-export everything from the provider module for backward compatibility
// Re-export types and context from the def module for backward compatibility
export {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
@@ -64,13 +62,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
// Create stable useItem function that:
// 1. Uses registered function if available (from BlobbiPage)
// 2. Falls back to built-in hook if no registration
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Try registered function first (from BlobbiPage)
if (context?.registerRef.current) {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using registered item-use function');
}
return context.registerRef.current(itemId, action, quantity);
return context.registerRef.current(itemId, action);
}
// Check if fallback can handle it
@@ -88,7 +86,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using fallback item-use hook');
}
return fallbackItemUse.useItem(itemId, action, quantity);
return fallbackItemUse.useItem(itemId, action);
}, [context, fallbackItemUse]);
// Determine canUseItems: true if registered OR fallback can use
@@ -136,14 +134,14 @@ export function useBlobbiActionsRegistration(
useItemRef.current = useItemFn;
// Create a stable wrapper that delegates to the ref
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
if (!useItemRef.current) {
return {
success: false,
error: 'Item use function not available',
};
}
return useItemRef.current(itemId, action, quantity);
return useItemRef.current(itemId, action);
}, []);
// Update refs and notify only when canUseItems actually changes
@@ -0,0 +1,75 @@
/**
* BlobbiActionsContextDef
*
* Lightweight context definition and types for the Blobbi actions system.
* Separated from the provider component to avoid react-refresh warnings.
*/
import { createContext } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item (always uses once).
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
@@ -10,75 +10,13 @@
* BlobbiPage, both of which are lazy-loaded.
*/
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of using an item via the context.
*/
export interface UseItemResult {
/** Whether the use was successful */
success: boolean;
/** Stats that changed (key = stat name, value = delta) */
statsChanged?: Record<string, number>;
/** Error message if failed */
error?: string;
}
/**
* Function signature for using an item.
*/
export type UseItemFunction = (
itemId: string,
action: InventoryAction,
quantity?: number
) => Promise<UseItemResult>;
/**
* Context value for Blobbi actions (consumer side).
*/
export interface BlobbiActionsContextValue {
/**
* Use an inventory item on the current companion.
* Works even without BlobbiPage registration (uses fallback).
*/
useItem: UseItemFunction;
/** Whether an item use operation is currently in progress */
isUsingItem: boolean;
/** Whether items can be used (companion exists and profile loaded) */
canUseItems: boolean;
/** Check if an item is on cooldown (recently attempted) */
isItemOnCooldown: (itemId: string) => boolean;
/** Clear cooldown for an item */
clearItemCooldown: (itemId: string) => void;
}
/**
* Internal context value (includes registration functions).
*/
export interface BlobbiActionsContextInternal {
/** Register item-use functionality (called by BlobbiPage) */
registerRef: React.MutableRefObject<UseItemFunction | null>;
/** Whether items can currently be used (via registration) */
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
/** Whether an item is currently being used (via registration) */
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
/** Force update consumers (called sparingly) */
notifyUpdate: () => void;
/** Subscribe to updates */
subscribe: (callback: () => void) => () => void;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
import {
BlobbiActionsContext,
type UseItemFunction,
type BlobbiActionsContextInternal,
} from './BlobbiActionsContextDef';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -1,26 +1,24 @@
/**
* HangingItems
*
* Displays inventory items as hanging elements from the top of the screen.
* Displays available items as hanging elements from the top of the screen.
* Each item appears as a circle connected to the top by a thin vertical line,
* creating a playful, spatial feel.
*
* Items are reusable abilities sourced from the shop catalog — they are
* always available and not consumed on use.
*
* State Model:
* - Container states: hidden → opening → open → closing → hidden
* - Hanging items = available inventory that can still be released
* - Hanging items = catalog items available for the selected action
* - Released/dropped items = instances currently in the world (tracked with unique IDs)
* - Multiple instances of the same item type can exist simultaneously on the ground
*
* Key Design Principle:
* The hanging row represents "releasable quantity" - clicking releases ONE instance
* and immediately decrements the visible quantity. A new hanging copy remains if
* quantity > 1. The released instance tracks separately with a unique instance ID.
*
* Features:
* - Smooth open/close slide animations (items descend/ascend)
* - Thin vertical lines from the top of screen
* - Circular containers for hanging items
* - Click releases item: one instance falls, remaining quantity stays hanging
* - Click releases item: one instance falls to the ground
* - Multiple dropped instances of same item type can exist
* - Contact detection: items auto-use when touching Blobbi
* - Click-to-use: click landed items to use them
@@ -119,7 +117,7 @@ interface HangingItemsProps {
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
/**
* Callback when an item is collected by Blobbi (contact).
* @deprecated Use onItemUse instead for proper item consumption flow.
* @deprecated Use onItemUse instead for proper item-use flow.
*/
onItemCollected?: (item: CompanionItem) => void;
/**
@@ -156,7 +154,7 @@ const HANGING_CONFIG = {
baseFallDistance: 500,
/** Ground offset from bottom of viewport */
defaultGroundOffset: 40,
/** Size of quantity badge */
/** Size of badge (unused — kept for config consistency) */
badgeSize: 20,
/** Size of landed item hitbox for contact detection */
landedItemSize: 40,
@@ -406,7 +404,7 @@ export function HangingItems({
// Track how many instances of each item type have been released (not yet used)
// Key: item.id (type ID), Value: count of released instances
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
// Counter for generating unique instance IDs
const instanceCounterRef = useRef(0);
@@ -566,7 +564,7 @@ export function HangingItems({
// Start the loop
animationRef.current = requestAnimationFrame(animate);
}, []);
}, [calculateFallDuration]);
// Cleanup animation on unmount
useEffect(() => {
@@ -670,7 +668,7 @@ export function HangingItems({
});
// Also remove from zone tracking
itemsInZoneRef.current.delete(instanceId);
// Decrement the released count for this item type (since the instance is now consumed)
// Decrement the released count for this item type (instance removed from screen)
setReleasedCountByItemId(prev => {
const next = new Map(prev);
const currentCount = next.get(item.id) || 0;
@@ -985,15 +983,9 @@ export function HangingItems({
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
};
// Calculate hanging items with their remaining quantities
// An item appears in the hanging row if (quantity - releasedCount) > 0
const hangingItems = items
.map(item => {
const releasedCount = releasedCountByItemId.get(item.id) || 0;
const remainingQuantity = item.quantity - releasedCount;
return { ...item, quantity: remainingQuantity };
})
.filter(item => item.quantity > 0);
// All items are always visible — they are abilities, not consumable inventory.
// No quantity filtering needed.
const hangingItems = items;
// Should we render the hanging container?
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
@@ -1033,7 +1025,7 @@ export function HangingItems({
>
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
<p className="text-sm text-muted-foreground text-center">
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
</p>
</div>
</div>
@@ -1102,8 +1094,8 @@ export function HangingItems({
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
}}
onClick={() => handleItemClick(item, itemX)}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
title={item.name}
aria-label={`${item.name}. Click to release.`}
>
{/* Item emoji */}
<span
@@ -1114,24 +1106,6 @@ export function HangingItems({
>
{item.emoji}
</span>
{/* Quantity badge */}
<span
className={cn(
"absolute -top-1 -right-1",
"flex items-center justify-center",
"bg-primary text-primary-foreground",
"text-xs font-semibold rounded-full",
"shadow-md"
)}
style={{
minWidth: HANGING_CONFIG.badgeSize,
height: HANGING_CONFIG.badgeSize,
padding: '0 5px',
}}
>
{item.quantity}
</span>
</button>
</div>
);
+1 -1
View File
@@ -76,10 +76,10 @@ export { useBlobbiItemUse } from './useBlobbiItemUse';
// Context
export {
BlobbiActionsContext,
BlobbiActionsProvider,
useBlobbiActions,
useBlobbiActionsRegistration,
} from './BlobbiActionsContext';
export { BlobbiActionsProvider } from './BlobbiActionsProvider';
// Components
export { CompanionActionMenu } from './CompanionActionMenu';
+2 -2
View File
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
/**
* Normalized item representation for the companion UI.
* This is a simplified view of inventory items optimized for rendering.
* This is a simplified view of shop catalog items optimized for rendering.
*/
export interface CompanionItem {
/** Unique item ID (matches shop item ID) */
@@ -74,7 +74,7 @@ export interface CompanionItem {
emoji: string;
/** Item category */
category: ShopItemCategory;
/** Quantity available in inventory */
/** Availability (always Infinity — items are reusable abilities) */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
@@ -27,13 +27,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
canUseItemForStage,
getStageRestrictionMessage,
@@ -59,7 +55,7 @@ import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
import type { UseItemFunction } from './BlobbiActionsProvider';
import type { UseItemFunction } from './BlobbiActionsContextDef';
// ─── Configuration ────────────────────────────────────────────────────────────
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const queryClient = useQueryClient();
// Fetch profile if not provided
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
const { profile: fetchedProfile } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
@@ -232,16 +228,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
// Core mutation for using items
// Core mutation for using items (always uses once)
const mutation = useMutation({
mutationFn: async ({
itemId,
action,
quantity = 1,
}: {
itemId: string;
action: InventoryAction;
quantity?: number;
}): Promise<{ statsChanged: Record<string, number> }> => {
// ─── Validation ───
if (!user?.pubkey) {
@@ -259,11 +253,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error('No companion selected');
}
// Validate quantity
if (quantity < 1) {
throw new Error('Quantity must be at least 1');
}
// Check stage restrictions
if (!canUseAction(companion, action)) {
const message = getStageRestrictionMessage(companion, action);
@@ -283,15 +272,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
@@ -319,17 +299,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects ───
// ─── Apply Item Effects (single use) ───
const isEggCompanion = companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const healthDelta = shopItem.effect.health ?? 0;
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = applyStat(currentHealth, healthDelta);
}
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
@@ -339,15 +315,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const hygieneDelta = shopItem.effect.hygiene ?? 0;
const happinessDelta = shopItem.effect.happiness ?? 0;
let currentHygiene = statsAfterDecay.hygiene ?? 0;
let currentHappiness = statsAfterDecay.happiness ?? 0;
for (let i = 0; i < quantity; i++) {
currentHygiene = applyStat(currentHygiene, hygieneDelta);
currentHappiness = applyStat(currentHappiness, happinessDelta);
}
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -362,11 +331,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
}
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -414,36 +380,19 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
updateCompanionInCache(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(profile.allTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
// ─── Invalidate Queries ───
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
},
onSuccess: (_, { itemId, action, quantity = 1 }) => {
onSuccess: (_, { itemId, action }) => {
const shopItem = getShopItemById(itemId);
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
});
// Track daily mission progress
@@ -468,7 +417,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
// Wrapper function that matches UseItemFunction signature and includes cooldown check
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
// Check cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
@@ -481,7 +430,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
}
try {
const result = await mutation.mutateAsync({ itemId, action, quantity });
const result = await mutation.mutateAsync({ itemId, action });
return {
success: true,
statsChanged: result.statsChanged,
@@ -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 () => {
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
}
/**
* Resolve inventory items for a specific action/category.
* Resolve available items for a specific action/category from the shop catalog.
*
* Items are sourced from the full shop catalog — all items are
* available as reusable abilities/tools, filtered only by stage.
*
* Uses the centralized `canUseItemForStage` function to ensure consistent
* stage-based filtering across all UIs:
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
* filters out all egg-only items from the companion interaction system.
*/
function resolveItemsForAction(
storage: StorageItem[],
action: CompanionMenuAction,
stage: 'egg' | 'baby' | 'adult'
): CompanionItem[] {
@@ -89,13 +90,10 @@ function resolveItemsForAction(
// Sleep action has no items
if (!category) return [];
const allItems = getLiveShopItems();
const items: CompanionItem[] = [];
for (const storageItem of storage) {
if (storageItem.quantity <= 0) continue;
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
for (const shopItem of allItems) {
if (shopItem.type !== category) continue;
// Use centralized stage-based filtering
@@ -104,17 +102,17 @@ function resolveItemsForAction(
// - Food/Toys: only for baby/adult (excluded for eggs)
// - Medicine: must have health effect
// - Hygiene: must have hygiene or happiness effect
const usability = canUseItemForStage(storageItem.itemId, stage);
const usability = canUseItemForStage(shopItem.id, stage);
if (!usability.canUse) {
continue;
}
items.push({
id: storageItem.itemId,
id: shopItem.id,
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: storageItem.quantity,
quantity: Infinity,
effect: shopItem.effect,
});
}
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
return;
}
// Resolve items for this action
const items = resolveItemsForAction(profile.storage, action, stage);
// Resolve items for this action from the catalog (not inventory)
const items = resolveItemsForAction(action, stage);
setMenuState(prev => ({
...prev,
@@ -42,7 +42,6 @@ export interface ItemUseResult {
export type UseItemCallback = (
itemId: string,
action: InventoryAction,
quantity: number
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
/**
@@ -67,14 +66,14 @@ export interface UseCompanionItemUseResult {
isUsingItem: boolean;
/** Get the action type for an item category */
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
/** Get the inventory action for a menu action */
/** Get the care action for a menu action */
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* Map item categories to inventory actions.
* Map item categories to care actions.
* This is the canonical mapping for how items are used.
*/
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
@@ -85,14 +84,14 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
};
/**
* Map menu actions to inventory actions (they match by design).
* Map menu actions to item-based care actions (they match by design).
*/
export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, InventoryAction | null> = {
feed: 'feed',
play: 'play',
medicine: 'medicine',
clean: 'clean',
sleep: null, // Sleep is a special action, not an inventory action
sleep: null, // Sleep is a special action, not item-based
};
// ─── Hook Implementation ──────────────────────────────────────────────────────
@@ -108,8 +107,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
* Usage:
* ```tsx
* const { useItem, isUsingItem } = useCompanionItemUse({
* onUseItem: async (itemId, action, qty) => {
* return await executeUseItem({ itemId, action, quantity: qty });
* onUseItem: async (itemId, action) => {
* return await executeUseItem({ itemId, action });
* },
* onSuccess: (result) => removeItemFromScreen(result.item),
* onFailure: (result) => keepItemOnScreen(result.item),
@@ -134,7 +133,7 @@ export function useCompanionItemUse({
}, []);
/**
* Get the inventory action for a menu action.
* Get the care action for a menu action.
*/
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
@@ -187,7 +186,7 @@ export function useCompanionItemUse({
try {
// Execute the use callback
const useResult = await onUseItem(item.id, inventoryAction, 1);
const useResult = await onUseItem(item.id, inventoryAction);
if (useResult.success) {
const result: ItemUseResult = {
+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 */
+95 -61
View File
@@ -6,6 +6,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
KIND_BLOBBI_STATE,
BLOBBI_ECOSYSTEM_NAMESPACE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
@@ -26,62 +27,90 @@ function chunkArray<T>(array: T[], size: number): T[][] {
}
/**
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
* Hook to fetch Blobbi companions (Kind 31124) owned by the logged-in user.
*
* Two modes:
* - **No dList** (default): Fetches ALL the user's blobbi events by author +
* ecosystem namespace tag. This is the authoritative source of truth —
* the user authored these events, so we don't need a secondary index.
* - **With dList**: Fetches only the specified d-tags. Use this when you only
* need a specific subset (e.g. the companion layer needs just one blobbi).
*
* 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) {
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
// Determine the mode: 'all' fetches everything, 'dlist' fetches by specific d-tags
const mode = dList === undefined ? 'all' : 'dlist';
// Create a stable query key based on sorted d-tags (for dlist mode)
const sortedDList = useMemo(() => {
if (!dList || dList.length === 0) return null;
if (mode === 'all' || !dList || dList.length === 0) return null;
return [...dList].sort();
}, [dList]);
}, [mode, dList]);
const queryKeyDTags = sortedDList?.join(',') ?? '';
// Query key segment: 'all' for fetch-all mode, comma-joined d-tags for dlist mode
const queryKeySegment = mode === 'all' ? 'all' : (sortedDList?.join(',') ?? '');
// Main query to fetch all companions from relays
// Main query to fetch companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryKey: ['blobbi-collection', user?.pubkey, queryKeySegment],
queryFn: async ({ signal }) => {
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
if (!user?.pubkey) {
console.log('[useBlobbisCollection] No pubkey, returning empty');
return { companionsByD: {}, companions: [] };
}
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
let allEvents: NostrEvent[];
// 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) {
if (mode === 'all') {
// Fetch ALL the user's blobbi events — author is the source of truth
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
};
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
console.log('[Blobbi] 31124 query filter (all):', JSON.stringify(filter, null, 2));
const events = await nostr.query([filter], { signal });
allEvents.push(...events);
allEvents = await nostr.query([filter], { signal });
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
console.log('[useBlobbisCollection] Fetch-all returned', allEvents.length, 'events');
} else {
// Fetch by specific d-tags (for companion layer etc.)
if (!sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] Empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
console.log('[Blobbi] dList:', sortedDList);
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
allEvents = [];
for (const chunk of chunks) {
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': chunk,
};
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);
@@ -123,7 +152,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
return { companionsByD, companions };
},
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
enabled: !!user?.pubkey && (mode === 'all' || (!!sortedDList && sortedDList.length > 0)),
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
@@ -132,46 +161,51 @@ 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) {
if (user?.pubkey) {
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
queryKey: ['blobbi-collection', user.pubkey, queryKeySegment],
});
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
}, [queryClient, user?.pubkey, queryKeySegment]);
// 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 queryKeySegment. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different query modes).
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]);
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, queryKeySegment],
{
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
},
);
}
}, [queryClient, user?.pubkey, queryKeySegment]);
// Memoize return values for stability
const companionsByD = query.data?.companionsByD ?? {};
@@ -190,7 +224,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)
+2 -2
View File
@@ -288,7 +288,7 @@ export interface BlobbiCompanion {
}
/**
* Stored item in user's profile inventory
* Stored item in user's profile (from purchases)
*/
export interface StorageItem {
itemId: string; // Must match a ShopItem.id
@@ -316,7 +316,7 @@ export interface BlobbonautProfile {
coins: number;
/** Petting level (interaction counter) */
pettingLevel: number;
/** Purchased items inventory */
/** Purchased items storage */
storage: StorageItem[];
/** All tags preserved for republishing */
allTags: string[][];
+1 -88
View File
@@ -9,7 +9,7 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
@@ -27,18 +27,6 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
// ─── Types ────────────────────────────────────────────────────────────────────
/** Tour dev actions for the first-hatch tour */
interface FirstHatchTourDevActions {
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
skipPostRequirement: () => void;
/** Reset the entire first-hatch tour so it can be tested again from scratch */
resetTour: () => void;
/** Current tour step id, or null if not active */
currentStepId: string | null;
/** Whether the tour has been completed */
isCompleted: boolean;
}
interface BlobbiDevEditorProps {
/** Whether the editor modal is open */
isOpen: boolean;
@@ -50,8 +38,6 @@ interface BlobbiDevEditorProps {
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
/** Whether an update is in progress */
isUpdating?: boolean;
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
tourDevActions?: FirstHatchTourDevActions;
}
/** Updates that can be applied to a Blobbi */
@@ -184,7 +170,6 @@ export function BlobbiDevEditor({
companion,
onApply,
isUpdating = false,
tourDevActions,
}: BlobbiDevEditorProps) {
// ─── Local State ───
// Initialize from companion values
@@ -545,79 +530,7 @@ export function BlobbiDevEditor({
</div>
</div>
{/* ─── First-Hatch Tour Controls ─── */}
{tourDevActions && (
<>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
<Badge variant="outline" className="text-xs">
{tourDevActions.isCompleted
? 'Completed'
: tourDevActions.currentStepId
? tourDevActions.currentStepId
: 'Not started'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Test the first-hatch tour flow without needing to create a real post.
</p>
<div className="flex flex-wrap gap-2">
{/* A. Skip Post Requirement */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.skipPostRequirement();
}}
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
className="gap-2 text-xs"
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
>
<SkipForward className="size-3.5" />
Skip Post
</Button>
{/* B. Restart First-Hatch Tour */}
<Button
variant="outline"
size="sm"
onClick={() => {
tourDevActions.resetTour();
}}
className="gap-2 text-xs"
title="Reset the entire first-hatch tour state so it can be tested again"
>
<RefreshCw className="size-3.5" />
Restart Tour
</Button>
{/* C. Reset Blobbi to Egg */}
<Button
variant="outline"
size="sm"
onClick={() => {
setStage('egg');
setState('active');
tourDevActions.resetTour();
}}
disabled={companion.stage === 'egg'}
className="gap-2 text-xs"
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
>
<Egg className="size-3.5" />
Reset to Egg + Tour
</Button>
</div>
{companion.stage !== 'egg' && stage === 'egg' && (
<p className="text-xs text-amber-500">
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
</p>
)}
</div>
</>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
+1 -1
View File
@@ -9,7 +9,7 @@ import { Theater } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useEmotionDev } from './EmotionDevContext';
import { useEmotionDev } from './useEmotionDev';
import { isLocalhostDev } from './index';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
+2 -54
View File
@@ -10,26 +10,10 @@
* - Is purely for visual testing/debugging
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { useState, useCallback, type ReactNode } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -68,40 +52,4 @@ export function EmotionDevProvider({ children }: EmotionDevProviderProps) {
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
+2 -1
View File
@@ -35,5 +35,6 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
// Emotion testing tools
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
export { EmotionDevProvider } from './EmotionDevContext';
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
+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,
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface EmotionDevContextValue {
/** Current dev emotion override (null = use default/neutral) */
devEmotion: BlobbiEmotion | null;
/** Set the dev emotion override */
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
/** Clear the dev emotion override (back to neutral) */
clearDevEmotion: () => void;
/** Whether dev emotion is active */
isDevEmotionActive: boolean;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
// ─── Hooks ────────────────────────────────────────────────────────────────────
/**
* Hook to access dev emotion state.
* Returns null values in production for safety.
*/
export function useEmotionDev(): EmotionDevContextValue {
const context = useContext(EmotionDevContext);
// Outside localhost dev or if no provider, return safe defaults
if (!isLocalhostDev() || !context) {
return {
devEmotion: null,
setDevEmotion: () => {},
clearDevEmotion: () => {},
isDevEmotionActive: false,
};
}
return context;
}
/**
* Get the effective emotion for a Blobbi.
* In dev mode with an override, returns the dev emotion.
* Otherwise returns the provided emotion or 'neutral'.
*/
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
const { devEmotion, isDevEmotionActive } = useEmotionDev();
// Dev override takes precedence (only in localhost dev)
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
return devEmotion;
}
return baseEmotion ?? 'neutral';
}
+351
View File
@@ -438,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}
/>
);
}
+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,
@@ -1,17 +1,15 @@
import { useMemo, useState } from 'react';
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
import { useMemo } from 'react';
import { Package, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Tooltip,
TooltipContent,
@@ -20,7 +18,7 @@ import {
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { ShopItem } from '../types/shop.types';
import { getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn } from '@/lib/utils';
import { ItemEffectDisplay } from './ItemEffectDisplay';
@@ -31,228 +29,169 @@ interface BlobbiInventoryModalProps {
profile: BlobbonautProfile | null;
/** The current companion (needed for stage-based restrictions) */
companion: BlobbiCompanion | null;
/** Called when user wants to use an item. Opens the use flow. */
onUseItem?: (itemId: string, quantity: number) => void;
/** Called when user wants to use an item. Always uses once. */
onUseItem?: (itemId: string) => void;
/** Whether an item is currently being used */
isUsingItem?: boolean;
}
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
// ── Shared items content (used by both standalone modal and unified shop modal) ──
interface BlobbiInventoryContentProps {
profile: BlobbonautProfile | null;
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
export function BlobbiInventoryContent({
profile,
profile: _profile,
companion,
onUseItem,
isUsingItem = false,
}: BlobbiInventoryContentProps) {
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
const [quantity, setQuantity] = useState(1);
const [showUseDialog, setShowUseDialog] = useState(false);
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
const isEmpty = inventoryItems.length === 0;
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem) return;
setSelectedItem(item);
setQuantity(1);
setShowUseDialog(true);
};
const handleConfirmUse = () => {
if (!selectedItem || !onUseItem || isUsingItem) return;
onUseItem(selectedItem.itemId, quantity);
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
};
const handleCloseUseDialog = (isOpen: boolean) => {
if (!isOpen) {
setShowUseDialog(false);
setSelectedItem(null);
setQuantity(1);
}
};
const maxQuantity = selectedItem?.quantity ?? 1;
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 1) {
setQuantity(1);
} else {
setQuantity(Math.min(value, maxQuantity));
}
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
onUseItem(item.itemId);
};
return (
<>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
<p className="text-sm text-muted-foreground max-w-sm">
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
</p>
<div className="px-4 sm:px-6 py-3 sm:py-4">
{isEmpty ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-10 text-muted-foreground" />
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
<p className="text-sm text-muted-foreground max-w-sm">
No items are available for your Blobbi's current stage.
</p>
</div>
) : (
<div className="grid gap-2 sm:gap-3">
{inventoryItems.map(item => (
<div
key={item.itemId}
className={cn(
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
item.canUse ? "hover:border-primary/30" : "opacity-70"
)}
>
{/* Top row on mobile: Icon + Name/Type + Button */}
<div className="flex items-center gap-3 sm:contents">
{/* Item Icon */}
<div className="relative shrink-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
<div className={cn(
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
!item.canUse && "grayscale"
)}>
{item.icon}
</div>
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
{/* Quantity Badge */}
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
×{item.quantity}
</Badge>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleSelectItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
Use
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{/* Item Info - Name and Type */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
{item.type}
</Badge>
</div>
{/* Effect preview - desktop only inline */}
<div className="hidden sm:block">
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{/* Show blocked reason - desktop only inline */}
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Use Item Confirmation Dialog */}
{selectedItem && companion && (
<InventoryUseConfirmDialog
open={showUseDialog}
onOpenChange={handleCloseUseDialog}
item={selectedItem}
companion={companion}
quantity={quantity}
maxQuantity={maxQuantity}
onIncrease={handleIncrease}
onDecrease={handleDecrease}
onQuantityChange={handleQuantityInput}
onConfirm={handleConfirmUse}
isUsing={isUsingItem}
/>
{/* Use Button */}
{onUseItem && (
item.canUse ? (
<Button
size="sm"
onClick={() => handleUseItem(item)}
disabled={isUsingItem}
className="shrink-0"
>
{isUsingItem ? (
<Loader2 className="size-4 animate-spin" />
) : (
'Use'
)}
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
disabled
className="shrink-0"
>
Use
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{item.reason || 'Cannot use this item'}</p>
</TooltipContent>
</Tooltip>
)
)}
</div>
{/* Mobile only: Effect preview and blocked reason below */}
<div className="sm:hidden pl-13 space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs capitalize">
{item.type}
</Badge>
<ItemEffectDisplay effect={item.effect} variant="inline" />
</div>
{!item.canUse && item.reason && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{item.reason}
</p>
)}
</div>
</div>
))}
</div>
)}
</>
</div>
);
}
@@ -298,153 +237,3 @@ export function BlobbiInventoryModal({
</Dialog>
);
}
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
interface InventoryUseConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ResolvedInventoryItem;
companion: BlobbiCompanion;
quantity: number;
maxQuantity: number;
onIncrease: () => void;
onDecrease: () => void;
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onConfirm: () => void;
isUsing: boolean;
}
function InventoryUseConfirmDialog({
open,
onOpenChange,
item,
companion,
quantity,
maxQuantity,
onIncrease,
onDecrease,
onQuantityChange,
onConfirm,
isUsing,
}: InventoryUseConfirmDialogProps) {
const totalEffect = useMemo(() => {
if (!item.effect) return null;
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
const currentStats = { ...companion.stats };
for (let i = 0; i < quantity; i++) {
for (const stat of statKeys) {
const delta = item.effect[stat];
if (delta !== undefined) {
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
}
}
}
const result: Record<string, number> = {};
for (const stat of statKeys) {
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
if (delta !== 0) {
result[stat] = delta;
}
}
return Object.keys(result).length > 0 ? result : null;
}, [item.effect, companion.stats, quantity]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
<DialogHeader>
<DialogTitle>Use Item</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Item Preview */}
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{item.name}</h3>
<p className="text-sm text-muted-foreground">
{item.quantity} in inventory
</p>
</div>
</div>
{/* Quantity Selector */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Quantity</label>
<span className="text-xs text-muted-foreground">
Max: {maxQuantity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onDecrease}
disabled={quantity <= 1 || isUsing}
>
<Minus className="size-4" />
</Button>
<Input
type="number"
min="1"
max={maxQuantity}
value={quantity}
onChange={onQuantityChange}
disabled={isUsing}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={onIncrease}
disabled={quantity >= maxQuantity || isUsing}
>
<Plus className="size-4" />
</Button>
</div>
</div>
{/* Effects Summary */}
{totalEffect && (
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
<h4 className="text-sm font-medium mb-2">
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
</h4>
<ItemEffectDisplay effect={totalEffect} variant="badges" />
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUsing}
>
Cancel
</Button>
<Button
onClick={onConfirm}
disabled={isUsing}
className="min-w-24"
>
{isUsing ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Using...
</>
) : (
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15 -31
View File
@@ -16,17 +16,16 @@ import {
import type { ShopItem } from '../types/shop.types';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
import { getLiveShopItems } from '../lib/blobbi-shop-items';
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import { cn, formatCompactNumber } from '@/lib/utils';
type TopTab = 'items' | 'shop';
/** Resolved inventory item with shop metadata and usability info */
/** Resolved catalog item with shop metadata and usability info */
interface ResolvedInventoryItem extends ShopItem {
itemId: string;
quantity: number;
canUse: boolean;
reason?: string;
}
@@ -39,7 +38,7 @@ interface BlobbiShopModalProps {
initialTab?: TopTab;
// ── Inventory props (passed through) ──
companion: BlobbiCompanion | null;
onUseItem?: (itemId: string, quantity: number) => void;
onUseItem?: (itemId: string) => void;
isUsingItem?: boolean;
}
@@ -80,28 +79,24 @@ export function BlobbiShopModal({
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
// ── Inventory items resolution ──
// ── Items resolution — sourced from the full catalog (not inventory) ──
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
if (!profile) return [];
const stage = companion?.stage ?? 'egg';
const allCatalogItems = getLiveShopItems();
const result: ResolvedInventoryItem[] = [];
for (const storageItem of profile.storage) {
const item = getShopItemById(storageItem.itemId);
if (!item) continue;
const usability = canUseItemForStage(storageItem.itemId, stage);
for (const item of allCatalogItems) {
const usability = canUseItemForStage(item.id, stage);
result.push({
...item,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
itemId: item.id,
canUse: usability.canUse,
reason: usability.reason,
});
}
return result;
}, [profile, companion?.stage]);
}, [companion?.stage]);
// ── Inventory use item handler ──
const [usingItemId, setUsingItemId] = useState<string | null>(null);
@@ -109,7 +104,7 @@ export function BlobbiShopModal({
const handleUseItem = (item: ResolvedInventoryItem) => {
if (!item.canUse || isUsingItem || !onUseItem) return;
setUsingItemId(item.itemId);
onUseItem(item.itemId, 1);
onUseItem(item.itemId);
};
// Clear usingItemId when isUsingItem goes false
@@ -138,7 +133,7 @@ export function BlobbiShopModal({
Items
{!inventoryEmpty && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
{inventoryItems.length}
</Badge>
)}
{topTab === 'items' && (
@@ -265,7 +260,7 @@ function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridPr
);
}
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
// ─── Items Grid (catalog, tile layout) ────────────────────────────────────────
interface ItemsGridProps {
items: ResolvedInventoryItem[];
@@ -275,20 +270,16 @@ interface ItemsGridProps {
onGoToShop: () => void;
}
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<Package className="size-8 text-muted-foreground/60" />
</div>
<p className="text-sm text-muted-foreground mb-4">
No items yet. Visit the shop to stock up!
<p className="text-sm text-muted-foreground">
No items are available for your Blobbi's current stage.
</p>
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
<ShoppingBag className="size-3.5" />
Browse Shop
</Button>
</div>
);
}
@@ -308,13 +299,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
)}
>
{/* Quantity badge */}
<Badge
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
>
{item.quantity}
</Badge>
{/* Icon */}
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
@@ -7,7 +7,7 @@
* Used by:
* - BlobbiShopItemRow (shop listing)
* - BlobbiPurchaseDialog (purchase confirmation)
* - BlobbiInventoryModal (inventory listing)
* - BlobbiInventoryModal (items listing)
* - BlobbiActionInventoryModal (action item selection)
*
* All consumers should use this component to ensure consistent display of item effects.
@@ -192,30 +192,6 @@ export function ItemEffectDisplay({
return null;
}
// ─── Utility Exports ──────────────────────────────────────────────────────────
/**
* Format effects as a summary string (for compatibility with existing code).
* This is a drop-in replacement for formatEffectSummary in blobbi-shop-utils.ts.
*
* @deprecated Use <ItemEffectDisplay variant="inline" /> instead
*/
export function formatEffectSummary(effect: ItemEffect | undefined, maxEffects = 4): string {
const entries = getSortedEffectEntries(effect);
if (entries.length === 0) {
return 'No effects';
}
const displayEntries = maxEffects !== undefined ? entries.slice(0, maxEffects) : entries;
return displayEntries
.map(([stat, value]) => `${formatStatValue(value)} ${STAT_LABELS[stat]}`)
.join(', ');
}
/**
* Get sorted effect entries for custom rendering.
* Useful when you need to iterate over effects yourself.
*/
export { getSortedEffectEntries };
@@ -1,133 +0,0 @@
/**
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
*
* Rendered directly in the BlobbiPage layout so the experience feels
* focused and guided. Adapts its messaging based on the current tour step.
*
* When the post mission is completed, the card stays visible with a
* celebratory completed state for ~2s (the parent auto-advances after
* that delay). This ensures the user sees the checkmark before the
* flow progresses to the egg-tap phase.
*/
import { Send, Check, MousePointerClick } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { FirstHatchTourStepId } from '../lib/tour-types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourCardProps {
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Current tour step id for adaptive messaging */
currentStep: FirstHatchTourStepId | null;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourCard({
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
currentStep,
}: FirstHatchTourCardProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
// Determine which phase of the card to show
const isPostStep = currentStep === 'show_hatch_card';
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|| currentStep === 'egg_crack_stage_1'
|| currentStep === 'egg_crack_stage_2'
|| currentStep === 'egg_crack_stage_3';
return (
<div className="w-full max-w-sm mx-auto space-y-4">
{/* Title + description */}
<div className="text-center space-y-1.5">
<h3 className="text-lg font-semibold">
{isClickStep
? `Tap ${capitalizedName} to hatch!`
: postCompleted && isPostStep
? `${capitalizedName} heard you!`
: `${capitalizedName} is ready to hatch!`}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{isClickStep
? `Tap the egg to help ${capitalizedName} break free.`
: postCompleted && isPostStep
? 'Your post was shared. Get ready to hatch...'
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
</p>
</div>
{/* Mission card - only during post step */}
{isPostStep && (
<div className="rounded-xl border bg-card p-4 space-y-3">
{postCompleted ? (
/* ── Completed state — celebratory, stays visible ── */
<div className="flex flex-col items-center gap-2 py-2">
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
<Check className="size-5 text-emerald-500" />
</div>
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
Post shared!
</p>
<p className="text-xs text-muted-foreground">
Continuing in a moment...
</p>
</div>
) : (
/* ── Pending state — post mission ── */
<>
<div className="flex items-start gap-3">
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium">Share a hatch post</p>
<p className="text-xs text-muted-foreground">
Your post must include:
</p>
<p className="text-xs font-medium text-primary break-words">
{requiredPhrase}
</p>
</div>
</div>
<Button
size="sm"
className="w-full"
onClick={onCreatePost}
>
<Send className="size-3.5 mr-2" />
Create Post
</Button>
</>
)}
</div>
)}
{/* Tap hint during click steps */}
{isClickStep && (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<MousePointerClick className="size-4" />
<span>Tap the egg</span>
</div>
)}
{/* Extra hint for post step */}
{isPostStep && !postCompleted && (
<p className="text-xs text-center text-muted-foreground">
You can add extra text before or after the required phrase.
</p>
)}
</div>
);
}
@@ -1,119 +0,0 @@
/**
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
*
* Tells the user their egg is about to hatch and guides them to create a post.
* Contains a single mission: create the hatch post.
*/
import { Egg, Send, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
// ─── Types ────────────────────────────────────────────────────────────────────
interface FirstHatchTourModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Blobbi's display name */
blobbiName: string;
/** The exact phrase the user needs to include in their post */
requiredPhrase: string;
/** Whether the post mission has been completed */
postCompleted: boolean;
/** Open the post composer */
onCreatePost: () => void;
/** Advance the tour (called after post is confirmed complete) */
onContinue: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FirstHatchTourModal({
open,
onOpenChange,
blobbiName,
requiredPhrase,
postCompleted,
onCreatePost,
onContinue,
}: FirstHatchTourModalProps) {
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
{/* Header with egg accent */}
<div className="px-6 pt-8 pb-4 text-center space-y-3">
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
<Egg className="size-7 text-amber-500" />
</div>
<DialogTitle className="text-xl font-bold">
{capitalizedName} is ready to hatch!
</DialogTitle>
<p className="text-sm text-muted-foreground leading-relaxed">
Share a post to the Nostr network and help {capitalizedName} break free.
</p>
</div>
{/* Mission card */}
<div className="px-6 pb-4">
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-start gap-3">
{/* Status indicator */}
<div className={
postCompleted
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
}>
{postCompleted && <Check className="size-3 text-emerald-500" />}
</div>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium">
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
</p>
<p className="text-xs text-muted-foreground">
Post must include the phrase:
</p>
<p className="text-xs font-medium text-primary break-words">
{requiredPhrase}
</p>
</div>
</div>
{!postCompleted && (
<Button
size="sm"
className="w-full"
onClick={onCreatePost}
>
<Send className="size-3.5 mr-2" />
Create Post
</Button>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 pb-6">
{postCompleted ? (
<Button className="w-full" onClick={onContinue}>
Continue
</Button>
) : (
<p className="text-xs text-center text-muted-foreground">
You can add extra text before or after the required phrase.
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
-226
View File
@@ -1,226 +0,0 @@
/**
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
*
* Orchestration only -- no rendering, no animations.
* The hook manages:
* - Ordered step progression
* - Persisted state via localStorage (survives refresh / close)
* - Derived booleans for UI consumption
* - Safe advance / goTo / complete / reset actions
*
* Activation is handled separately by useFirstHatchTourActivation,
* which calls `start()` when all preconditions are met.
*
* ────────────────────────────────────────────────────────────────
* Future integration points
* ────────────────────────────────────────────────────────────────
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
* to decide whether to start the tour.
* 2. UI components read `state.currentStepId` and render overlays,
* spotlights, modals, or animation cues accordingly.
* 3. Animation components call `actions.advance()` when their
* sequence finishes (for autoAdvance steps).
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
* on the user interaction.
* 5. EggGraphic receives a visual-state prop derived from
* `state.currentStepId` -- it does NOT own the tour logic.
*/
import { useMemo, useCallback, useRef } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
type FirstHatchTourStepId,
type FirstHatchTourPersistedState,
type TourState,
type TourActions,
} from '../lib/tour-types';
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* localStorage key for the first hatch tour state.
* Not user-scoped because onboarding state is device-local and the tour
* is inherently tied to "first ever egg on this device". If multi-user
* support on the same device becomes a concern, scope by pubkey.
*/
const STORAGE_KEY = 'blobbi:tour:first-hatch';
/** Pre-computed lookup: stepId -> index */
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
);
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
// ─── Result Type ──────────────────────────────────────────────────────────────
export interface UseFirstHatchTourResult {
/** Reactive tour state for UI consumption */
state: TourState<FirstHatchTourStepId>;
/** Actions to drive the tour forward */
actions: TourActions<FirstHatchTourStepId>;
/**
* Convenience: check if the current step matches a given id.
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
*/
isStep: (stepId: FirstHatchTourStepId) => boolean;
/**
* Convenience: check if the current step is one of the given ids.
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
*/
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
/**
* The current step definition (with autoAdvance metadata), or null.
*/
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useFirstHatchTour(): UseFirstHatchTourResult {
// ── Persisted state ──
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
STORAGE_KEY,
FIRST_HATCH_TOUR_DEFAULT_STATE,
);
// Stable ref to current persisted state so callbacks never go stale.
const persistedRef = useRef(persisted);
persistedRef.current = persisted;
// ── Helpers ──
const updatePersisted = useCallback(
(patch: Partial<FirstHatchTourPersistedState>) => {
setPersisted((prev) => ({
...prev,
...patch,
updatedAt: Date.now(),
}));
},
[setPersisted],
);
// ── Actions ──
const start = useCallback(() => {
const p = persistedRef.current;
// No-op if already active or completed
if (p.completed || p.currentStepId !== null) return;
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
if (!firstStep) return;
updatePersisted({ currentStepId: firstStep.id });
}, [updatePersisted]);
const advance = useCallback(() => {
const p = persistedRef.current;
if (p.completed || p.currentStepId === null) return;
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
if (currentIndex === undefined) return;
const nextIndex = currentIndex + 1;
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
// Past the end -- complete
updatePersisted({ currentStepId: null, completed: true });
return;
}
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
if (nextStep.id === 'complete') {
// Reaching the 'complete' terminal step means the tour is done
updatePersisted({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: nextStep.id });
}
}, [updatePersisted]);
const goTo = useCallback(
(stepId: FirstHatchTourStepId) => {
if (!STEP_INDEX_MAP.has(stepId)) {
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
}
if (stepId === 'complete') {
updatePersisted({ currentStepId: null, completed: true });
} else {
updatePersisted({ currentStepId: stepId, completed: false });
}
},
[updatePersisted],
);
const complete = useCallback(() => {
updatePersisted({ currentStepId: null, completed: true });
}, [updatePersisted]);
const reset = useCallback(() => {
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
}, [setPersisted]);
// ── Derived state ──
const currentStepIndex = persisted.currentStepId !== null
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
: -1;
const state = useMemo((): TourState<FirstHatchTourStepId> => {
const isActive = persisted.currentStepId !== null && !persisted.completed;
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
return {
isActive,
currentStepId: persisted.currentStepId,
currentStepIndex,
totalSteps,
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
isCompleted: persisted.completed,
progress: persisted.completed
? 1
: currentStepIndex >= 0
? currentStepIndex / LAST_REAL_STEP_INDEX
: 0,
};
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
start,
advance,
goTo,
complete,
reset,
}), [start, advance, goTo, complete, reset]);
// ── Convenience helpers ──
const isStep = useCallback(
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
[persisted.currentStepId],
);
const isAnyStep = useCallback(
(...stepIds: FirstHatchTourStepId[]) => {
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
},
[persisted.currentStepId],
);
const currentStepDef = currentStepIndex >= 0
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
: null;
return {
state,
actions,
isStep,
isAnyStep,
currentStepDef,
};
}
@@ -1,164 +0,0 @@
/**
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
*
* This hook checks all preconditions and calls `tour.actions.start()` when
* the tour should activate. It is intentionally separated from the tour
* state machine so that:
* - The state machine stays generic and reusable.
* - Activation rules are centralized in one place.
* - The rules are easy to read and modify.
*
* ────────────────────────────────────────────────────────────────
* Activation rules (ALL must be true):
* ────────────────────────────────────────────────────────────────
* 1. The companions list is loaded (not loading / error).
* 2. The user has exactly 1 Blobbi.
* 3. That Blobbi is in the egg stage.
* 4. No Blobbi is in baby or adult stage.
* 5. The tour has not been completed yet (checked via profile tag
* AND localStorage fallback).
*
* Completion is authoritative from the Blobbonaut profile event
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
* is a secondary signal for in-progress UI state and as a fallback
* when the profile hasn't been updated yet.
* ────────────────────────────────────────────────────────────────
*/
import { useEffect, useMemo } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface FirstHatchTourActivationInput {
/** The full list of the user's Blobbi companions */
companions: BlobbiCompanion[];
/** Whether the companions list is still loading */
isLoading: boolean;
/** The tour hook result (localStorage-based state machine) */
tour: UseFirstHatchTourResult;
/**
* Whether onboarding is already marked complete in the Blobbonaut profile
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
* When true, the tour will not activate regardless of localStorage state.
*/
profileOnboardingDone?: boolean;
}
export interface FirstHatchTourActivationResult {
/**
* Whether all preconditions for activating the tour are met right now.
* This is a derived boolean -- it does NOT mean the tour IS active,
* just that it SHOULD be activated. The tour may already be active
* from a previous render or a persisted state.
*/
shouldActivate: boolean;
/**
* Whether the tour is eligible (preconditions met and not yet completed).
* Useful for hiding UI that should only appear during the tour window.
*/
isEligible: boolean;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Evaluates activation preconditions and auto-starts the tour when met.
*
* Usage:
* ```ts
* const tour = useFirstHatchTour();
* const activation = useFirstHatchTourActivation({
* companions,
* isLoading: companionsLoading,
* tour,
* profileOnboardingDone: profile?.onboardingDone,
* });
* ```
*/
export function useFirstHatchTourActivation({
companions,
isLoading,
tour,
profileOnboardingDone: _profileOnboardingDone = false,
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
// ── Precondition evaluation ──
const { shouldActivate, isEligible } = useMemo(() => {
// Can't evaluate until data is loaded
if (isLoading) {
return { shouldActivate: false, isEligible: false };
}
// localStorage tour already completed — this is always authoritative
if (tour.state.isCompleted) {
return { shouldActivate: false, isEligible: false };
}
// Must have exactly 1 companion
if (companions.length !== 1) {
return { shouldActivate: false, isEligible: false };
}
const onlyBlobbi = companions[0];
// That companion must be an egg
if (onlyBlobbi.stage !== 'egg') {
return { shouldActivate: false, isEligible: false };
}
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
// but kept explicit for clarity and future-proofing if rules change)
const hasBabyOrAdult = companions.some(
(c) => c.stage === 'baby' || c.stage === 'adult',
);
if (hasBabyOrAdult) {
return { shouldActivate: false, isEligible: false };
}
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
// Some older accounts had `onboarding_done` migrated to
// `blobbi_onboarding_done=true` before the first-hatch tour
// existed, so they never experienced it. When the user is in the
// exact single-egg/no-evolved-companions state (all checks above
// passed), we intentionally ignore `profileOnboardingDone` so
// those accounts can still enter the tour.
//
// This is safe because:
// - The localStorage `tour.state.isCompleted` check above
// already prevents re-triggering for users who HAVE finished
// the tour.
// - The egg-stage + single-companion guard means this only
// fires for users who genuinely haven't hatched yet.
//
// TODO: Replace `blobbi_onboarding_done` with a dedicated
// `blobbi_first_hatch_tour_done` tag so onboarding completion
// and tour completion are tracked independently. Once that tag
// is in place, remove this safeguard and gate activation on the
// new tag instead.
// ───────────────────────────────────────────────────────────────
// (profileOnboardingDone is intentionally NOT checked here)
// All preconditions met
const eligible = true;
// Only activate if the tour is not already running
const activate = !tour.state.isActive;
return { shouldActivate: activate, isEligible: eligible };
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
// ── Auto-start effect ──
// When all preconditions are met and the tour hasn't started yet,
// start it. This fires once and then `shouldActivate` flips to false
// because `tour.state.isActive` becomes true.
useEffect(() => {
if (shouldActivate) {
tour.actions.start();
}
}, [shouldActivate, tour.actions]);
return { shouldActivate, isEligible };
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Blobbi Tour Module
*
* Provides the orchestration layer for guided tours / tutorials.
* Currently implements the first-egg hatch tour.
*
* Architecture:
* - tour-types.ts: Step definitions, persisted state shape, generic types
* - useFirstHatchTour: State machine (step progression, persistence, actions)
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
*
* UI components import from this barrel and read tour state to decide
* what to render. They call tour actions (advance, goTo, complete) in
* response to user interactions or animation completions.
*/
// ── Types (generic tour infrastructure) ──
export type {
TourStepDef,
TourPersistedState,
TourState,
TourActions,
} from './lib/tour-types';
// ── First Hatch Tour - Types & Constants ──
export {
FIRST_HATCH_TOUR_STEPS,
FIRST_HATCH_TOUR_DEFAULT_STATE,
} from './lib/tour-types';
export type {
FirstHatchTourStepId,
FirstHatchTourPersistedState,
} from './lib/tour-types';
// ── First Hatch Tour - Hooks ──
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
export type {
FirstHatchTourActivationInput,
FirstHatchTourActivationResult,
} from './hooks/useFirstHatchTourActivation';
// ── First Hatch Tour - Components ──
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
-140
View File
@@ -1,140 +0,0 @@
/**
* Tour System - Core Types
*
* Generic, reusable types for step-based guided tours.
* The tour system is designed to be:
* - Easy to extend with new tours (define steps + config)
* - Easy to reorder steps (change the STEPS array)
* - Persistent across page refreshes (localStorage)
* - Decoupled from rendering (UI reads state, doesn't own it)
*/
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
/**
* A tour step definition.
*
* Each step has a unique id and optional metadata that future UI layers
* can use to decide what to render (spotlights, modals, animations, etc.).
*/
export interface TourStepDef<StepId extends string = string> {
/** Unique identifier for this step */
id: StepId;
/**
* Whether this step auto-advances (e.g. animations) or waits for
* an explicit `advance()` / `goTo()` call from the UI.
* Default: false (manual).
*/
autoAdvance?: boolean;
}
/**
* Persisted state for a tour.
* Stored in localStorage so tours survive refresh / close / return.
*/
export interface TourPersistedState<StepId extends string = string> {
/** Current step id, or null when the tour is not yet started */
currentStepId: StepId | null;
/** Whether the tour has been completed */
completed: boolean;
/** Unix ms timestamp of last state change (for debugging / analytics) */
updatedAt: number;
}
/**
* Full runtime state exposed by a tour hook.
*/
export interface TourState<StepId extends string = string> {
/** Whether the tour is currently active (started and not yet completed) */
isActive: boolean;
/** Current step id, or null when idle / completed */
currentStepId: StepId | null;
/** 0-based index of the current step in the steps array, or -1 */
currentStepIndex: number;
/** Total number of steps */
totalSteps: number;
/** Whether the current step is the last one before completion */
isLastStep: boolean;
/** Whether the tour has been completed (persisted) */
isCompleted: boolean;
/** Progress as a fraction 0..1 */
progress: number;
}
/**
* Actions exposed by a tour hook.
*/
export interface TourActions<StepId extends string = string> {
/** Start the tour from the first step (no-op if already active or completed) */
start: () => void;
/** Advance to the next step. Completes the tour if on the last step. */
advance: () => void;
/** Jump to a specific step by id. Throws if the step doesn't exist. */
goTo: (stepId: StepId) => void;
/** Mark the tour as completed and reset to idle. */
complete: () => void;
/** Reset the tour entirely (clears persisted state). For dev/testing. */
reset: () => void;
}
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
/**
* Step ids for the first-egg hatch tour.
*
* Flow:
* 1. idle — initial state (auto-advances immediately)
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
* 4. egg_crack_stage_1 — click 1: crack expands
* 5. egg_crack_stage_2 — click 2: crack expands further
* 6. egg_crack_stage_3 — click 3: crack reaches edges
* 7. egg_opening — shell opens (auto-advance after animation)
* 8. egg_hatching — bright light + baby reveal (auto-advance)
* 9. complete — terminal, marks tour done
*
* The order here matches the intended flow. To reorder steps,
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
*/
export type FirstHatchTourStepId =
| 'idle'
| 'show_hatch_card'
| 'egg_glowing_waiting_click'
| 'egg_crack_stage_1'
| 'egg_crack_stage_2'
| 'egg_crack_stage_3'
| 'egg_opening'
| 'egg_hatching'
| 'complete';
/**
* Ordered step definitions for the first hatch tour.
*
* To add / remove / reorder steps, edit this array.
* The tour state machine walks through these in order.
*/
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
{ id: 'idle' },
{ id: 'show_hatch_card' },
{ id: 'egg_glowing_waiting_click' },
{ id: 'egg_crack_stage_1' },
{ id: 'egg_crack_stage_2' },
{ id: 'egg_crack_stage_3' },
{ id: 'egg_opening', autoAdvance: true },
{ id: 'egg_hatching', autoAdvance: true },
{ id: 'complete' },
];
/**
* Persisted state shape for the first hatch tour.
*/
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
/**
* Default persisted state for a brand-new first hatch tour.
*/
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
currentStepId: null,
completed: false,
updatedAt: 0,
};
+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>
);
}
+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,
};
+48
View File
@@ -876,3 +876,51 @@ export const ACTION_EMOTION_MAP: Record<ActionType, BlobbiEmotion> = {
export function getActionEmotion(action: ActionType): BlobbiEmotion {
return ACTION_EMOTION_MAP[action];
}
// ─── Feed Attenuation ─────────────────────────────────────────────────────────
/**
* Produce a lighter version of a visual recipe suitable for feed cards.
*
* Feed Blobbis are rendered at a smaller size (size-48/56 vs size-64+) and
* need to remain readable at a glance. This function keeps all facial parts
* (eyes, mouth, eyebrows) and extras untouched — they are already sized
* relative to the SVG viewBox — but reduces body-effect particle counts
* and removes flies to prevent visual clutter at small sizes.
*
* The input recipe is produced by the same `resolveStatusRecipe()` used
* by the room view, so thresholds and priorities are identical.
*/
export function attenuateRecipeForFeed(recipe: BlobbiVisualRecipe): BlobbiVisualRecipe {
// Empty / no body effects → return as-is (stable reference path)
if (!recipe.bodyEffects) return recipe;
const { bodyEffects, ...rest } = recipe;
const attenuated: BodyEffectsRecipe = {};
// Dirt marks: reduce count by ~40%, lower intensity cap
if (bodyEffects.dirtMarks?.enabled) {
attenuated.dirtMarks = {
...bodyEffects.dirtMarks,
count: Math.max(1, Math.ceil((bodyEffects.dirtMarks.count ?? 3) * 0.6)),
intensity: Math.min(bodyEffects.dirtMarks.intensity ?? 0.6, 0.55),
};
}
// Stink clouds: reduce count, remove flies entirely
if (bodyEffects.stinkClouds?.enabled) {
attenuated.stinkClouds = {
...bodyEffects.stinkClouds,
count: Math.max(1, Math.ceil((bodyEffects.stinkClouds.count ?? 3) * 0.5)),
flies: false,
flyCount: 0,
};
}
// Anger rise: pass through unchanged (single overlay, scales with SVG)
if (bodyEffects.angerRise) {
attenuated.angerRise = bodyEffects.angerRise;
}
return { ...rest, bodyEffects: attenuated };
}
+4 -5
View File
@@ -297,11 +297,10 @@ export function AdvancedSettings() {
<div className="px-3 pt-3 pb-4 space-y-4">
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
<div>
<h3 className="text-sm font-medium">Request to Vanish</h3>
<h3 className="text-sm font-medium">Delete Account</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Permanently request all relays to delete your data, including your profile,
posts, reactions, and direct messages. This action is irreversible and legally
binding in some jurisdictions (NIP-62).
Permanently delete your data from the network, including your profile,
posts, reactions, and direct messages. This action is irreversible.
</p>
</div>
<Button
@@ -310,7 +309,7 @@ export function AdvancedSettings() {
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setVanishDialogOpen(true)}
>
Request to Vanish
Delete Account
</Button>
</div>
</div>
+105 -43
View File
@@ -1,13 +1,15 @@
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 { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/** Get a tag value by name. */
@@ -66,6 +68,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 +106,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 websiteUrl = getWebsiteUrl(event.tags, metadata);
const banner = metadata.banner;
const websiteUrl = sanitizeUrl(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 +191,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 +222,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 +310,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 +339,16 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
</div>
</div>
</div>
{nsiteRef && nsiteEvent && (
<NsitePreviewDialog
event={nsiteEvent}
appName={name}
appPicture={picture}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
)}
</div>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@
import { useMemo } from 'react';
import { Play, Pause, Music, ListMusic, Podcast, Clock } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
import { useAuthor } from '@/hooks/useAuthor';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
/**
* Auto-minimizes the audio player when the user navigates to a different page.
+1 -33
View File
@@ -3,39 +3,7 @@ import { Award } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCardTilt } from '@/hooks/useCardTilt';
/** Parsed NIP-58 badge definition data. */
export interface BadgeData {
identifier: string;
name: string;
description?: string;
image?: string;
imageDimensions?: string;
thumbs: Array<{ url: string; dimensions?: string }>;
}
/** Parse a kind 30009 badge definition event into structured data. */
export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
if (event.kind !== 30009) return null;
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
if (!identifier) return null;
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
const description = event.tags.find(([n]) => n === 'description')?.[1];
const imageTag = event.tags.find(([n]) => n === 'image');
const image = imageTag?.[1];
const imageDimensions = imageTag?.[2];
const thumbs: Array<{ url: string; dimensions?: string }> = [];
for (const tag of event.tags) {
if (tag[0] === 'thumb' && tag[1]) {
thumbs.push({ url: tag[1], dimensions: tag[2] });
}
}
return { identifier, name, description, image, imageDimensions, thumbs };
}
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
interface BadgeContentProps {
event: NostrEvent;
+1 -1
View File
@@ -28,7 +28,7 @@ import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { parseBadgeDefinition } from '@/components/BadgeContent';
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
+2 -2
View File
@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
import { parseProfileBadges } from '@/lib/parseProfileBadges';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
+1 -1
View File
@@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { cn } from '@/lib/utils';
interface BadgeDisplayItem {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { Award } from 'lucide-react';
import type { BadgeData } from '@/components/BadgeContent';
import type { BadgeData } from '@/lib/parseBadgeDefinition';
import { useCardTilt } from '@/hooks/useCardTilt';
import { cn } from '@/lib/utils';
+29 -3
View File
@@ -3,17 +3,41 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions';
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
if (!companion) return null;
const isSleeping = companion?.state === 'sleeping';
const isEgg = companion?.stage === 'egg';
const isSleeping = companion.state === 'sleeping';
// ── Project stats forward in time, then resolve visual recipe ──
// Feed cards show a snapshot, not a live ticker, so we call the pure
// calculateProjectedDecay() once per render instead of using the
// interval-based useProjectedBlobbiState hook. This gives us the
// same decay math the room view uses (applyBlobbiDecay under the
// hood) without any per-card setInterval overhead.
const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => {
if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' };
const { stats } = calculateProjectedDecay(companion);
const result = resolveStatusRecipe(stats);
// Attenuate body effects for feed-card size, then apply sleep overlay
const attenuated = attenuateRecipeForFeed(result.recipe);
const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated;
return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label };
}, [companion, isEgg, isSleeping]);
if (!companion) return null;
return (
<div className="flex flex-col items-center py-4">
{/* Blobbi visual — same as /blobbi hero */}
{/* Blobbi visual — reflects current condition */}
<div className="relative">
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
<BlobbiStageVisual
@@ -21,6 +45,8 @@ export function BlobbiStateCard({ event }: { event: NostrEvent }) {
size="lg"
animated={!isSleeping}
lookMode="forward"
recipe={feedRecipe}
recipeLabel={feedRecipeLabel}
className="size-48 sm:size-56"
/>
</div>
+2 -1
View File
@@ -34,6 +34,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
// --- Helpers ---
@@ -159,7 +160,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
const location = locationRaw ? parseLocation(locationRaw) : undefined;
const summary = getTag(event.tags, 'summary');
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
const eventCoord = useMemo(() => getEventCoord(event), [event]);
const dateStr = useMemo(() => formatDetailDate(event), [event]);
+2 -84
View File
@@ -2,8 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
import type { CoreThemeColors } from '@/themes';
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
import type { NostrEvent } from '@nostrify/nostrify';
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
@@ -12,12 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
export function getColors(tags: string[][]): string[] {
return tags
.filter(([n]) => n === 'c')
.map(([, v]) => v)
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
}
/** Compute a best-fit grid: cols × rows for n items. */
function gridDimensions(n: number): { cols: number; rows: number } {
@@ -193,82 +187,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
);
}
// ─── Palette → theme mapping ─────────────────────────────
function hexContrast(hex1: string, hex2: string): number {
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
}
function hexSaturation(hex: string): number {
return rgbToHsl(...hexToRgb(hex)).s;
}
/**
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
* contrast against `bgHsl`. Steps toward white or black depending on which
* direction gives better contrast. Returns the adjusted HSL string.
*/
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
const bg = parseHsl(bgHsl);
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
const { h, s, l } = parseHsl(hsl);
// Decide direction: go lighter if bg is dark, darker if bg is light
const goLighter = bgLum < 0.18;
let current = l;
for (let i = 0; i < 50; i++) {
current = goLighter
? Math.min(100, current + 2)
: Math.max(0, current - 2);
const rgb = hslToRgb(h, s, current);
const lum = getLuminance(...rgb);
const lighter = Math.max(bgLum, lum);
const darker = Math.min(bgLum, lum);
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
}
return formatHsl(h, s, current);
}
/**
* Map palette hex colors to CoreThemeColors with guaranteed readability:
* 1. background = darkest color
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
* 3. primary = most saturated remaining color; if contrast < 3:1 against
* background, adjust its lightness until it passes
*/
export function paletteToTheme(colors: string[]): CoreThemeColors {
if (colors.length === 0) {
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
}
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
const bgHex = sorted[0];
const bgHsl = hexToHslString(bgHex);
// Text: lightest palette color; override with white/black if contrast is too low
const textHex = sorted[sorted.length - 1];
let textHsl = hexToHslString(textHex);
if (hexContrast(textHex, bgHex) < 4.5) {
// Pick white or black — whichever contrasts better
const whiteContrast = hexContrast('#ffffff', bgHex);
const blackContrast = hexContrast('#000000', bgHex);
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
}
// Primary: most saturated of remaining colors; nudge lightness if needed
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
const pool = rest.length > 0 ? rest : [textHex];
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
let primaryHsl = hexToHslString(primaryHex);
if (hexContrast(primaryHex, bgHex) < 3) {
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
}
return { background: bgHsl, text: textHsl, primary: primaryHsl };
}
// ─── Main component ──────────────────────────────────────
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
+83 -16
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,8 +122,8 @@ const KIND_LABELS: Record<number, string> = {
30030: 'an emoji pack',
30054: 'a podcast episode',
30055: 'a podcast trailer',
3063: 'an asset',
30063: 'a release',
3063: 'a Zapstore asset',
30063: 'a Zapstore release',
30311: 'a stream',
30315: 'a status',
30617: 'a repository',
@@ -117,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',
@@ -142,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,
@@ -214,11 +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',
3063: 'asset',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/** Get a display name for an event based on its kind and tags. */
@@ -488,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';
@@ -526,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 (
@@ -586,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 ?? '';

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