Compare commits

..

40 Commits

Author SHA1 Message Date
Chad Curtis 0b11baad2a Fix egg companion: render egg visual, raise flash z-index, slower fade-out
- Remove egg stage filter from useBlobbiCompanionData (already done)
- Add egg rendering path to BlobbiCompanionVisual using BlobbiStageVisual
- Raise hatch flash z-index from 60 to 80 (above baby visual)
- Raise fade-out z-index to 90 with slower 2s duration
- Match fade timeout to 2.2s for smooth transition
2026-04-04 16:47:43 -05:00
Chad Curtis 6ecd3303db Fade to white after naming before revealing the Blobbi page
After confirming the name, the ceremony fades to white over 1.2s before
calling onComplete. This provides a smooth transition from the ceremony
overlay back to the dashboard instead of an abrupt cut.
2026-04-04 16:35:34 -05:00
Chad Curtis b02111d1dd Fix ceremony unmounting mid-hatch: remove isEgg guard from portal
The executeHatch publishes a baby event which updates the companion from
egg to baby. The isEgg check on the portal was killing the ceremony
immediately after the flash, before dialog/naming could render.
2026-04-04 16:32:25 -05:00
Chad Curtis a2ab53a72c Fix hatch ceremony: pre-populate name for existing eggs, reset on companion switch
- Pre-populate the naming input with the existing egg's name so the user
  can proceed (empty name was blocking the submit)
- Reset showHatchCeremony when selectedD changes to prevent auto-triggering
  the ceremony when switching to a different egg
2026-04-04 16:31:26 -05:00
Chad Curtis 0d262587f0 Fix ceremony overlay behind sidebar: portal to document.body, allow egg as companion
- Portal hatch ceremony to document.body with z-[100] (same pattern as lightbox)
  to escape the center column's z-0 stacking context
- Allow eggs to be set as floating companion (added 'egg' to canBeCompanion check)
2026-04-04 16:28:20 -05:00
Chad Curtis c810670df5 Adoption creates egg only, no auto-hatch ceremony
- Add eggOnly prop to BlobbiHatchingCeremony: creates the egg then
  auto-completes after 1.5s without proceeding to cracking/hatching
- BlobbiOnboardingFlow passes adoptionOnly through as eggOnly
- Adopted eggs stay as eggs; user must complete quests to trigger hatch
- Initial onboarding (first Blobbi) still runs the full ceremony
2026-04-04 16:26:41 -05:00
Chad Curtis 6a43933525 Hatch ceremony for existing eggs: reuse full onboarding ceremony
- Add existingCompanion prop to BlobbiHatchingCeremony
- When provided, skip egg creation/profile setup and jump to cracking phase
- Populate preview and tags from existing companion data
- Dev hatch button and quest hatch CTA both trigger the full ceremony
- Smaller egg visual in dashboard, hide egg names in hero and Blobbis tab
- Remove custom HatchCeremonyOverlay (replaced by real ceremony)
- Add CSS keyframes for shake/flash animations
2026-04-04 16:26:41 -05:00
Chad Curtis 6d27f76310 Fix all three bugs: companion holds position, mobile viewport, desktop containment
1. Companion holds position where dragged (endDrag sets isGrounded=true)
2. Mobile companion: listen to visualViewport resize for URL bar changes
3. Desktop: DashboardShell uses normal flow (h-dvh), fixed only on mobile
   (max-sidebar:fixed) to escape pb-overscroll
4. Photo overlay: absolute instead of fixed (contained within DashboardShell),
   X button at top-right relative to container, more opaque action buttons
2026-04-04 16:26:41 -05:00
Chad Curtis 191085bd7f Simplify photo modal: fullscreen blur overlay with polaroid and action circles
- Replace Dialog-based modal with simple fixed overlay
- Light backdrop blur (bg-background/60 backdrop-blur-sm) matching letter editor style
- Polaroid card centered with fade-in zoom animation
- Save and Post as floating circle buttons below the card
- Close button positioned below mobile top bar, no circle background
- Capacitor-compatible download using openUrl utility
2026-04-04 16:26:41 -05:00
Chad Curtis 770d6206d2 Fix button clickability, drawer collapse, and add 'bring home' button
- Add pointer-events-none to blur glow div that was intercepting clicks
- Action buttons container gets relative z-10 to sit above the Blobbi visual
- Move backdrop div outside the pointer-events-none absolute container
- Add gradient 'Bring [name] home' button to the exploring state
2026-04-04 16:26:41 -05:00
Chad Curtis fc1ee21b4c Fix drawer overlay: absolute container with drawer + tabs, hero unaffected
The entire drawer+tabs block is absolutely positioned (top-0 left-0 right-0)
over the hero. Drawer is in flow within this block so it pushes the tabs
down when open. The hero fills the full flex space underneath, unaffected
by the drawer state.
2026-04-04 16:26:41 -05:00
Chad Curtis 904b159630 Fix drawer/tab ordering: drawer first (in flow), tabs below — matches compose letter pattern
Drawer slides down from behind the top bar, pushing the tab bar down.
Tabs always sit at the bottom of the sticky block. This matches the
ComposeLetterSheet pattern: sticky top-0 wrapper → drawer → SubHeaderBar.
2026-04-04 16:26:41 -05:00
Chad Curtis d8454394a1 Fix pb-overscroll: use fixed positioning for DashboardShell to escape parent padding
The center column in MainLayout has pb-overscroll which adds large bottom
padding. The useLayoutOptions({ noOverscroll: true }) mechanism has a
render-cycle delay with lazy-loaded pages. Instead of fighting timing,
use fixed positioning (inset-0 with top-mobile-bar offset) so the shell
fills the viewport independently of parent padding.
2026-04-04 16:26:41 -05:00
Chad Curtis 0a72771c24 Move useLayoutOptions to top-level BlobbiPage to ensure noOverscroll is always active
Previously set inside BlobbiDashboard which only renders after data loads,
leaving pb-overscroll applied during loading/ceremony/selector states.
2026-04-04 16:26:41 -05:00
Chad Curtis 283feb04e9 Fix blank space at bottom: disable overscroll padding, collapse drawer on mobile
- Add noOverscroll: true to layout options (removes pb-overscroll on center column)
- Drawer starts collapsed on mobile (useIsMobile), expanded on desktop
- Clean up redundant padding on hero section
2026-04-04 16:26:41 -05:00
Chad Curtis 3312e02afe Fix tab bar: remove spacer gap and use pinned SubHeaderBar pattern
- Change outer sticky wrapper from top-mobile-bar to top-0 (removes gap)
- Add pinned prop to SubHeaderBar (matches ComposeLetterSheet pattern)
- SubHeaderBar handles its own sticky positioning and safe-area padding
2026-04-04 16:26:41 -05:00
Chad Curtis 7fd3e8d5d6 Fix layout: flex column hero fills remaining space, centered vertically
- DashboardShell: h-dvh flex column, inner container flex-1
- Hero section: flex-1 with justify-center instead of fixed minHeight + spacer
- Remove flex-[3] top spacer that was pushing content to the bottom
- Content naturally centers in whatever space remains after the tab bar
2026-04-04 16:26:41 -05:00
Chad Curtis c290368c53 More desktop top padding, disable main scroll, preserve bottom overflow
- Desktop top padding: sm:pt-16, md:pt-20
- DashboardShell: h-dvh overflow-hidden to lock scroll on Blobbi page
- Hero: overflow-x-hidden only (preserves bottom gradient bleed)
2026-04-04 16:26:41 -05:00
Chad Curtis 756f42d5e6 Fine-tune small screen layout: wider stats arc, top padding, reduced radius
- Stats arc spread slightly wider on small screens (90/130/160 degrees)
- Min radius raised from 110 to 140 for flatter arc (less horseshoe)
- Top padding pt-12 on mobile to push Blobbi below sticky tabs
- Hero min-height reduced to 60dvh, overflow hidden
2026-04-04 16:26:41 -05:00
Chad Curtis 67f6c7a4f3 Fix small screen overflow: responsive stats arc, smaller Blobbi, tighter care actions, overlay drawer
- Stats arc radius scales with container width via ResizeObserver (110px at 340px, 210px at 640px+)
- Blobbi visual: size-64 base, scaling up at 400px/sm/md/lg breakpoints
- Care action circles: size-16 base with gap-3 on small screens, scaling up at 400px
- Drawer overlays content instead of pushing it, with backdrop tap to dismiss
- All measurements based on actual container width, not window.innerWidth
2026-04-04 16:26:41 -05:00
Chad Curtis 75bb585b69 Fix duplicate egg creation during hatching ceremony
Add module-level guard (setupInFlightFor Set) to prevent the ceremony's
setup effect from creating duplicate eggs if the component remounts during
the same session (e.g. React strict mode double-mount, or parent re-render
causing unmount/remount). The ref-based guard (setupAttempted) resets on
remount since it creates a new ref, but the module-level Set persists
across mounts.

The guard is cleared after setup completes (or fails) so subsequent
adoptions can still create new eggs normally.
2026-04-04 16:26:41 -05:00
Chad Curtis d062ec7b12 Refactor stats: always-visible icon circles with warning badges, larger Blobbi, bobbing animation
- Stats always shown (no threshold filter), using getVisibleStats per stage
- Icon centered inside colored circle with progress ring, no text/numbers
- Warning/critical: small AlertTriangle badge on the icon's corner inside the circle
- Stat circles sized 4.5rem/5rem with colored fill backgrounds
- Blobbi visual bobs and sways based on happiness level (CSS keyframes)
- Blobbi container 68dvh, Care drawer open by default
- Desktop Blobbi up to size-[36rem] on lg screens
2026-04-04 16:26:41 -05:00
Chad Curtis 949f9fe256 Raise stats crown higher above Blobbi, tuck action buttons closer 2026-04-04 16:26:41 -05:00
Chad Curtis 76d15010e1 Crown stats above Blobbi, move action buttons below as flow elements
- Stats arc now curves as a crown above the Blobbi visual (inverted arc, center highest)
- Radius 210px for wide horizontal spread with gentle vertical curve
- Action buttons (Photo, Companion) sit below the Blobbi name in normal flow
- Remove top spacer, simplify hero layout to natural stacking order
- Hero min-height 70dvh for generous breathing room
2026-04-04 16:26:41 -05:00
Chad Curtis 6ac5b6a767 Enlarge Blobbi visual, hero buttons, and add breathing room
- Blobbi visual: size-96 / size-[28rem] / size-[32rem] (up from size-80 / size-96 / size-[28rem])
- Floating action circles: size-20 / size-24 (up from size-16 / size-[4.5rem])
- Icons: size-9 / size-10 (up from size-7 / size-8)
- Labels: text-xs (up from text-[10px])
- Hero section: pb-24 for room below the Blobbi for the action circles
2026-04-04 16:26:41 -05:00
Chad Curtis a44e5fb90a Move Photo + Companion to large floating circles in hero section corners
- Two large (4rem/4.5rem) circular buttons at the lower-left and lower-right of the Blobbi hero
- Photo button: pink radial gradient, Camera icon, 'Photo' label
- Companion button: violet gradient (inactive) / emerald (active), Footprints icon, 'Take along' / 'With you' labels
- Remove companion pill from Care tab (now in hero)
- Remove Photo from Blobbis tab quick actions (now in hero)
- Simplify CareTabContentProps (no more companion-related props)
2026-04-04 16:26:41 -05:00
Chad Curtis 6652b6cfb3 Redesign Blobbis tab: avatar grid with floating circles, no borders or cards
- Replace bordered BlobbiSelectorCard list with a simple flex-wrap grid of avatars
- Each Blobbi is its circular stage visual with name below, hover lift effect
- Selected Blobbi gets a ring highlight, companion badge in corner
- Needs-care indicator as small amber dot
- Adopt button is a + circle in the same grid
- Quick action row below: Photo, View, and dev tools as minimal icon buttons
- Remove redundant actions already in other tabs (companion toggle in Care, evolve in Quests)
- Update BlobbiSelectorPage to match the same borderless avatar grid style
- Remove old BlobbiSelectorCard and AdoptAnotherBlobbiCard components
2026-04-04 16:26:41 -05:00
Chad Curtis 36e7cb675e Redesign Quests tab with pill toggle, compact task rows, and inline actions
- Replace expandable card grid with Journey/Bounties pill toggle
- Each pane gets full drawer height — no more two lists competing for space
- Journey tasks shown as compact rounded rows with icons and descriptions
- Daily bounties as clean rows with claim buttons inline
- Hatch/Evolve CTA as gradient pill button when all tasks complete
- Start incubation/evolution directly from the Quests tab (no more modal dialogs)
- Remove StartIncubationDialog and StartEvolutionDialog modals
- Remove unused DailyMissionsPanel and TasksPanel imports
2026-04-04 16:26:41 -05:00
Chad Curtis 287f70070d Enlarge Items tab category indicator icons to size-4 with strokeWidth 4 2026-04-04 16:26:41 -05:00
Chad Curtis 50a394c5d1 Refine companion pill: passive gradient when active, vibrant gradient when inviting 2026-04-04 16:26:41 -05:00
Chad Curtis 479e4d87b7 Add 'Take with you' companion pill button to Care tab
- Large pill button below the care action circles
- Shows 'Take [name] with you' when not set, '[name] is with you' when active
- Toggles the floating companion on/off
- Only visible for baby/adult stage (not eggs)
- Subtle styling: muted bg when inactive, primary tint when active
2026-04-04 16:26:41 -05:00
Chad Curtis 0cbd518096 Redesign Care tab: big floaty action bubbles, collapse Feed/Play/Clean/Medicine into Items
- Replace 7 bordered grid buttons with 4 large floating action bubbles
- Items button opens the Items drawer tab directly
- Music and Sing remain as direct play actions (both boost happiness)
- Sleep/Wake toggle preserved for non-egg stages
- Each bubble has a stat badge showing the affected attribute
- Removed box borders for an open, bubbly, cute aesthetic
- Buttons fill the drawer space with generous sizing and hover lift effects
2026-04-04 16:26:41 -05:00
Chad Curtis d8e2cf842b Center Blobbi on page and remove dashboard shell padding
- Remove px/py padding from DashboardShell main element
- Center hero section vertically using 60dvh min-height
- Add useLayoutOptions({ hasSubHeader: true }) for proper mobile bar handling
2026-04-04 16:26:41 -05:00
Chad Curtis e3f27cefdf Enlarge blobbi and stat circles, add lucide icons to stat labels, move name below blobbi, widen arc 2026-04-04 16:26:41 -05:00
Chad Curtis d84a575d3a Widen stats arc spread and increase orbit radius 2026-04-04 16:26:41 -05:00
Chad Curtis 494a630879 Enlarge blobbi visual and curve stats arc around it 2026-04-04 16:26:41 -05:00
Chad Curtis 06593048e1 Remove inventory check for items — free to use without owning 2026-04-04 16:26:41 -05:00
Chad Curtis 4f82b6d3ca Refactor blobbi care UI: curved arc drawer tabs, larger visual, free items
Replace the bottom bar + modal navigation with a letter-compose-style
drawer system using SubHeaderBar arc tabs (Care, Items, Quests, Blobbis).

- Blobbi visual enlarged to ceremony size (size-56/64/72)
- All content accessible via sliding drawers beneath the arc bar
- ScrollArea with visible scrollbar for overflow (capped at ~3.5 rows)
- Items shown as a flat grid with no coins required, lucide stat
  indicators, and hover-lift micro-interaction
- Care actions as inline icon+label grid buttons
- Missions/quests and blobbi selector embedded in drawers
- Removed BlobbiBottomBar, ActionBarEditor, action bar preferences,
  BlobbiActionsModal, BlobbiShopModal, and BlobbiMissionsModal usage
2026-04-04 16:26:41 -05:00
Chad Curtis 74c0f2c39d Refine hatching ceremony: real hatch, golden aura, sparkles, typewriter dialog
- Hatch mutation fires during ceremony (egg -> baby), hatched blobbi shown
- Rotating golden incandescence with breathing scale behind baby
- Bright white-gold shine for contrast, sparkles everywhere (inner ring,
  outer ring, scattered field, drifting motes)
- Typewriter-style dialog from '???' with click-to-complete/advance
- Vibrant baby-blue post-hatch background gradient
- Feathered text backdrop (no hard box edges), rounded input with glow
- Egg sized down, baby blobbi large and centered
- Ceremony persists on refresh if onboardingDone is false
- Shake animations via DOM ref to prevent egg re-renders between cracks
2026-04-04 16:26:41 -05:00
Chad Curtis f5606dfb50 Replace blobbi onboarding with immersive hatching ceremony
Remove the multi-step onboarding flow (welcome screen, adoption question,
coin-gated preview card, confirmation dialog) and the entire first-hatch
tour system (post requirement, inline tour card, multi-step crack
orchestration in BlobbiPage).

Replace with a single BlobbiHatchingCeremony component: dark ambient
screen, huge breathing egg, click-to-crack with intensifying shakes,
screen-flash burst on hatch, sentimental birth text over lingering glow,
and a soft naming prompt. Profile + egg creation happen silently in the
background with no purchase required.

- Delete src/blobbi/tour/ (6 files, ~830 lines)
- Remove ~170 lines of tour orchestration from BlobbiPage
- Remove tour dev controls from BlobbiDevEditor
- Add hatching ceremony CSS animations (breathe, shake, burst, glow, text reveal)
- Every new egg uses the ceremony (initial onboarding + adopt another)
2026-04-04 16:26:41 -05:00
324 changed files with 8409 additions and 18559 deletions
-112
View File
@@ -1,112 +0,0 @@
---
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
@@ -1,229 +0,0 @@
============================================================
LOCKDOWN MODE DETECTOR REPORT
2026-04-06T23:40:58.170Z
============================================================
VERDICT: Lockdown Mode Likely Active
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
Score: 70% (8/12 key APIs blocked)
============================================================
API TEST RESULTS (detailed)
============================================================
------------------------------------------------------------
[BLOCKED] IndexedDB (weight: 3)
Client-side structured storage
Result: Can't find variable: indexedDB
Diagnostics:
uncaught: Can't find variable: indexedDB
------------------------------------------------------------
[BLOCKED] WebAssembly (weight: 2)
Binary instruction execution
Result: WebAssembly is undefined
Diagnostics:
typeof WebAssembly: undefined
WebAssembly global does not exist
------------------------------------------------------------
[BLOCKED] Web Locks API (weight: 3)
Cross-tab resource coordination
Result: navigator.locks is undefined
Diagnostics:
typeof navigator.locks: undefined
'locks' in navigator: false
navigator.locks is falsy
------------------------------------------------------------
[BLOCKED] Speech Synthesis (weight: 3)
Web Speech API (text-to-speech)
Result: window.speechSynthesis is undefined
Diagnostics:
typeof window.speechSynthesis: undefined
'speechSynthesis' in window: false
typeof SpeechSynthesisUtterance: undefined
speechSynthesis is falsy
------------------------------------------------------------
[BLOCKED] FileReader API (weight: 2)
Local file reading interface
Result: FileReader is undefined
Diagnostics:
typeof FileReader: undefined
FileReader constructor does not exist
------------------------------------------------------------
[AVAILABLE] File Constructor (weight: 2)
File object creation
Result: File created: name=test.txt size=4
Diagnostics:
typeof File: function
calling new File(['test'], 'test.txt', {type:'text/plain'})...
succeeded
f.name: test.txt
f.size: 4
f.type: text/plain
f instanceof Blob: true
------------------------------------------------------------
[BLOCKED] WebGL (weight: 2)
GPU-accelerated graphics
Result: all WebGL contexts returned null
Diagnostics:
getContext('webgl2'): null
getContext('webgl'): null
getContext('experimental-webgl'): null
------------------------------------------------------------
[BLOCKED] WebGL2 (weight: 1)
Advanced GPU graphics context
Result: getContext('webgl2') returned null
Diagnostics:
getContext('webgl2'): null
------------------------------------------------------------
[BLOCKED] Service Worker (weight: 1)
Background script registration
Result: navigator.serviceWorker not present
Diagnostics:
'serviceWorker' in navigator: false
typeof navigator.serviceWorker: undefined
------------------------------------------------------------
[BLOCKED] Web Share API (weight: 0)
Native sharing interface
Result: navigator.share is undefined
Diagnostics:
typeof navigator.share: undefined
typeof navigator.canShare: undefined
------------------------------------------------------------
[BLOCKED] Gamepad API (weight: 1)
Game controller input
Result: navigator.getGamepads not present
Diagnostics:
'getGamepads' in navigator: false
------------------------------------------------------------
[BLOCKED] WebRTC (weight: 2)
Real-time peer communication
Result: RTCPeerConnection is undefined
Diagnostics:
typeof RTCPeerConnection: undefined
typeof webkitRTCPeerConnection: undefined
------------------------------------------------------------
[AVAILABLE] FontFace API (weight: 1)
Dynamic font loading
Result: status: loaded
Diagnostics:
typeof FontFace: function
new FontFace() succeeded
ff.status: unloaded
ff.family: test
ff.status after load: loaded
------------------------------------------------------------
[AVAILABLE] Remote Fonts (weight: 2)
Loading fonts from network via data URI
Result: API works, load rejected: A network error occurred.
Diagnostics:
FontFace created with data URI
ff.status before load: unloaded
caught: DOMException: A network error occurred.
------------------------------------------------------------
[AVAILABLE] JIT Compilation (weight: 2)
JavaScript JIT optimization heuristic
Result: 99.0ms for 1M iterations (JIT likely)
Diagnostics:
running 1,000,000 iterations of Math.sqrt*Math.sin...
elapsed: 99.00ms
sum (to prevent dead-code elimination): -681.7597
threshold: <150ms suggests JIT active
verdict: likely JIT
------------------------------------------------------------
[BLOCKED] Notifications API (weight: 1)
Push notification permission
Result: Notification not in window
Diagnostics:
'Notification' in window: false
typeof Notification: undefined
------------------------------------------------------------
[BLOCKED] WebCodecs (weight: 1)
Low-level VideoDecoder API
Result: VideoDecoder is undefined
Diagnostics:
typeof VideoDecoder: undefined
typeof VideoEncoder: undefined
typeof AudioDecoder: function
------------------------------------------------------------
[AVAILABLE] PDF Embed (weight: 2)
Inline PDF rendering via embed/pdfViewerEnabled
Result: pdfViewerEnabled is true
Diagnostics:
navigator.pdfViewerEnabled: true
typeof navigator.pdfViewerEnabled: boolean
created and appended <embed type=application/pdf>
navigator.mimeTypes['application/pdf']: [object MimeType]
------------------------------------------------------------
[BLOCKED] SharedArrayBuffer (weight: 1)
Shared memory between workers
Result: SharedArrayBuffer is undefined
Diagnostics:
typeof SharedArrayBuffer: undefined
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
------------------------------------------------------------
[BLOCKED] Cache API (weight: 1)
Programmatic HTTP cache (CacheStorage)
Result: caches not in window
Diagnostics:
'caches' in window: false
typeof caches: undefined
------------------------------------------------------------
[BLOCKED] OPFS (weight: 2)
Origin Private File System (navigator.storage.getDirectory)
Result: navigator.storage.getDirectory is not a function
Diagnostics:
typeof navigator.storage: object
typeof navigator.storage.getDirectory: undefined
getDirectory method does not exist
============================================================
NAVIGATOR INFO
============================================================
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
platform: iPhone
vendor: Apple Computer, Inc.
language: en-US
languages: en-US
cookieEnabled: true
doNotTrack: null
maxTouchPoints: 5
hardwareConcurrency: 4
deviceMemory: N/A
pdfViewerEnabled: true
webdriver: false
connection: unavailable
mediaDevices: unavailable
storage: available
serviceWorker: unavailable
credentials: available
bluetooth: unavailable
gpu (WebGPU): unavailable
screenWidth: 393
screenHeight: 852
devicePixelRatio: 3
colorDepth: 24
============================================================
END OF REPORT
============================================================
+1 -3
View File
@@ -2,6 +2,4 @@ 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=""
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
# ALLOWED_HOSTS="*"
VITE_NOSTR_PUSH_PUBKEY=""
+2 -48
View File
@@ -57,22 +57,6 @@ 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
@@ -145,9 +129,8 @@ build-apk:
- npx vite build -l error
- cp dist/index.html dist/404.html
# Sync web assets to Capacitor Android project and register local plugins
# Sync web assets to Capacitor Android project
- npx cap sync android
- node scripts/patch-cap-config.mjs
# Build signed release APK
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
@@ -219,7 +202,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,wss://relay.dreamith.to,wss://relay.primal.net"
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
BLOSSOM_URL: "https://blossom.ditto.pub"
script:
- go install github.com/zapstore/zsp@latest
@@ -235,32 +218,3 @@ 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
@@ -1,68 +0,0 @@
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
+7 -138
View File
@@ -409,74 +409,6 @@ 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.
@@ -767,47 +699,23 @@ 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, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
```typescript
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
// Inside a mutation function:
const prev = await fetchFreshEvent(nostr, {
const freshEvent = await fetchFreshEvent(nostr, {
kinds: [10003],
authors: [user.pubkey],
});
const currentTags = prev?.tags ?? [];
const currentTags = freshEvent?.tags ?? [];
// ...modify tags...
await publishEvent({
kind: 10003,
content: prev?.content ?? '',
tags: newTags,
prev: prev ?? undefined,
});
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
```
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
#### 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.).
@@ -1403,10 +1311,6 @@ 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.
@@ -1484,7 +1388,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) and AAB to Google Play (`publish-google-play` job, tags only)
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
### Creating a Release
@@ -1494,7 +1398,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`, `publish-zapstore`, and `publish-google-play` 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`, and `publish-zapstore` stages.
### Zapstore Publishing
@@ -1586,39 +1490,4 @@ 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
### 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` | **Base64-encoded** contents of the Google Play API service account key JSON file. The CI job decodes it with `base64 -d` before passing it to `fastlane supply`. | 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. **Base64-encode** the key file:
```bash
# Linux
base64 -w0 service-account.json
# macOS
base64 -i service-account.json | tr -d '\n'
```
6. Add the base64-encoded value as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**. Do **not** paste the raw JSON — the CI script expects base64 and will fail to decode a raw value.
#### 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`)
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
-170
View File
@@ -1,175 +1,5 @@
# Changelog
## [2.8.0] - 2026-04-16
### Added
- Back up your secret key right from Profile settings -- reveal, copy, and save it to iCloud Keychain, Android Credential Manager, or a local file
- Blobbi mission progress now persists across page refreshes, so your hatching and evolution journey picks up right where you left off
### Changed
- AI chat has been overhauled with a cleaner layout, the Dork mascot across empty states, and a clear path to grab Shakespeare credits when you run out
- Friendly error banners now explain when you've hit the rate limit or run out of AI credits, instead of cryptic failures
### Fixed
- Avatar shape selection during signup now actually saves to your profile
- Blobbi interaction missions now tally correctly the moment you start incubating or evolving
- Blobbi task progress displays the right numbers immediately on page load instead of showing 0 until everything catches up
## [2.7.1] - 2026-04-16
### Added
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
### Changed
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
- Android's automatic cloud backup now excludes your wallet credentials
### Fixed
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
## [2.7.0] - 2026-04-14
### Added
- Customizable widget sidebar -- drag, drop, and rearrange widgets on your feed including Trending, Hot Posts, Bluesky, AI Chat, Blobbi, Music, Photos, Wikipedia, and more
- Blobbi rooms -- swipe between living spaces, clean up after your pet, and earn XP from daily care routines
- Native push notifications on iOS with author names, content previews, and smart grouping by category
- Haptic feedback throughout the app -- taps, buzzes, and pulses when you react, zap, repost, pull to refresh, play games, and interact with your Blobbi
- Hot Posts widget showing the most popular posts from your feed at a glance
### Changed
- Sidebar widgets are now clickable links that take you to their full pages
- Blobbi widget shows live stats with circular ring indicators and quick action buttons
### Fixed
- Zaps embedded in posts now render as proper inline cards instead of blank space
- Quote posts display media and Blobbi companions correctly
- Deep linking on Google Play works again
- Game controller buttons no longer trigger text selection on long-press on iOS
## [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
-184
View File
@@ -1,184 +0,0 @@
# 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.
-18
View File
@@ -361,21 +361,3 @@ Kind 16158 (replaceable) describes a weather station's configuration: name, geoh
NIP-BB defines a virtual pet lifecycle on Nostr. Kind 31124 (addressable) holds the current pet state across three stages (egg, baby, adult) with stats, appearance, and personality traits. Kind 14919 logs individual interactions, kind 14920 records breeding events, kind 14921 stores immutable lifecycle records, and kind 11125 (replaceable) holds the owner's profile with coins, achievements, and inventory.
#### Kind 11125 `content` JSON — `missions` field
The `content` of kind 11125 is a JSON object. Ditto extends it with a `missions` field that tracks daily and evolution mission progress:
```jsonc
{
"missions": {
"date": "2026-04-16", // ISO date string for the current daily mission set
"daily": [ /* Mission[] */ ],
"evolution": [ /* Mission[] active hatch/evolve tasks, cleared on stage transition */ ],
"rerolls": 2 // remaining daily mission rerolls
}
// ...other profile fields (coins, achievements, inventory, etc.)
}
```
Each `Mission` is either a **TallyMission** (`{ id, target, count }`) or an **EventMission** (`{ id, target, events: string[] }`) where `events` contains Nostr event IDs that satisfy the mission. Evolution missions are populated when incubation or evolution begins and cleared when the stage transition completes or is cancelled.
-11
View File
@@ -138,17 +138,6 @@ 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.8.0"
versionName "2.5.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -4
View File
@@ -11,12 +11,9 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capgo-capacitor-autofill-save-password')
implementation project(':capacitor-secure-storage-plugin')
implementation project(':capacitor-status-bar')
}
-2
View File
@@ -3,8 +3,6 @@
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -1,10 +1,7 @@
package pub.ditto.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.content.Context;
import android.util.Log;
import com.getcapacitor.Plugin;
@@ -17,10 +14,6 @@ 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 {
@@ -31,7 +24,6 @@ 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;
@@ -68,8 +60,7 @@ public class DittoNotificationPlugin extends Plugin {
if (userPubkey != null && relayUrlsRaw != null) {
SharedPreferences.Editor editor = prefs.edit()
.putString("userPubkey", userPubkey)
.putString("relayUrls", relayUrlsRaw)
.putString("notificationStyle", notificationStyle);
.putString("relayUrls", relayUrlsRaw);
if (enabledKindsRaw != null) {
editor.putString("enabledKinds", enabledKindsRaw);
}
@@ -79,46 +70,13 @@ public class DittoNotificationPlugin extends Plugin {
editor.remove("authors");
}
editor.apply();
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., 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,9 +1,7 @@
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;
@@ -13,36 +11,32 @@ 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 native plugins before super.onCreate.
// Register the native notification config plugin before super.onCreate
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(SandboxPlugin.class);
super.onCreate(savedInstanceState);
// 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;
}
// 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;
}
}
@@ -1,552 +0,0 @@
package pub.ditto.app;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.JavascriptInterface;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
*
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
* The React code can serve files identically regardless of platform.
*/
@CapacitorPlugin(name = "SandboxPlugin")
public class SandboxPlugin extends Plugin {
private static final String TAG = "SandboxPlugin";
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@PluginMethod
public void create(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
if (sandboxes.containsKey(sandboxId)) {
call.reject("Sandbox already exists: " + sandboxId);
return;
}
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
sandboxes.put(sandboxId, sandbox);
// Add the container (WebView + spinner overlay) on top of the
// Capacitor WebView. The parent is a CoordinatorLayout — using
// the wrong LayoutParams type causes a ClassCastException when
// it intercepts touch events.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
parent.addView(sandbox.container, params);
// The spinner is now visible. Navigation is deferred until the
// JS layer calls navigate() — this allows the caller to
// pre-fetch blobs while the spinner animates.
call.resolve();
});
}
@PluginMethod
public void navigate(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
call.resolve();
});
}
@PluginMethod
public void updateFrame(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject frame = call.getObject("frame");
if (frame == null) {
call.reject("Missing required parameter: frame");
return;
}
int x = frame.optInt("x", 0);
int y = frame.optInt("y", 0);
int width = frame.optInt("width", 0);
int height = frame.optInt("height", 0);
float density = getActivity().getResources().getDisplayMetrics().density;
int pxX = Math.round(x * density);
int pxY = Math.round(y * density);
int pxWidth = Math.round(width * density);
int pxHeight = Math.round(height * density);
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.container.setLayoutParams(params);
call.resolve();
});
}
@PluginMethod
public void respondToFetch(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
String requestId = call.getString("requestId");
if (requestId == null) {
call.reject("Missing required parameter: requestId");
return;
}
JSObject response = call.getObject("response");
if (response == null) {
call.reject("Missing required parameter: response");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
int status = response.optInt("status", 200);
String statusText = response.optString("statusText", "OK");
String bodyBase64 = response.optString("body", null);
Map<String, String> headers = new HashMap<>();
JSONObject headersObj = response.optJSONObject("headers");
if (headersObj != null) {
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
String key = it.next();
headers.put(key, headersObj.optString(key));
}
}
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
call.resolve();
}
@PluginMethod
public void postMessage(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
JSObject message = call.getObject("message");
if (message == null) {
call.reject("Missing required parameter: message");
return;
}
SandboxInstance sandbox = sandboxes.get(sandboxId);
if (sandbox == null) {
call.reject("Sandbox not found: " + sandboxId);
return;
}
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
call.resolve();
}
@PluginMethod
public void destroy(PluginCall call) {
String sandboxId = call.getString("id");
if (sandboxId == null) {
call.reject("Missing required parameter: id");
return;
}
mainHandler.post(() -> {
SandboxInstance sandbox = sandboxes.remove(sandboxId);
if (sandbox != null) {
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
if (parent != null) {
parent.removeView(sandbox.container);
}
sandbox.webView.destroy();
}
call.resolve();
});
}
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("requestId", requestId);
data.put("request", request);
notifyListeners("fetch", data);
}
void emitScriptMessage(String sandboxId, JSObject message) {
JSObject data = new JSObject();
data.put("id", sandboxId);
data.put("message", message);
notifyListeners("scriptMessage", data);
}
/**
* A single sandboxed WebView instance.
*/
private static class SandboxInstance {
final String id;
/** Wrapper layout that holds the WebView and the loading overlay. */
final FrameLayout container;
final WebView webView;
final SandboxPlugin plugin;
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
/** Native spinner overlay, shown while the sandbox content loads. */
private ProgressBar spinner;
SandboxInstance(String id, SandboxPlugin plugin) {
this.id = id;
this.plugin = plugin;
this.container = new FrameLayout(plugin.getActivity());
this.webView = new WebView(plugin.getActivity());
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setDatabaseEnabled(true);
webView.setBackgroundColor(Color.parseColor("#14161f"));
// Add JavaScript interface for script->native communication.
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
// Inject the bridge script and intercept requests.
webView.setWebViewClient(new SandboxWebViewClient(this));
// Build the container: WebView fills it, spinner overlays on top.
container.addView(webView, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
// Native spinner overlay — uses the Android indeterminate
// ProgressBar which animates on the render thread, so it keeps
// spinning even when the main/IO threads are busy.
spinner = new ProgressBar(plugin.getActivity());
spinner.setIndeterminate(true);
spinner.getIndeterminateDrawable().setColorFilter(
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
container.addView(spinner, spinnerParams);
// Dark background behind the spinner.
View overlay = new View(plugin.getActivity());
overlay.setBackgroundColor(Color.parseColor("#14161f"));
// Insert the overlay between the WebView (index 0) and spinner (index 1)
// so it covers the WebView but sits behind the spinner.
container.addView(overlay, 1, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
}
/** Remove the native loading overlay. Safe to call multiple times. */
void hideSpinner() {
if (spinner != null) {
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
spinner = null;
}
}
private static int dpToPx(SandboxPlugin plugin, int dp) {
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
return Math.round(dp * density);
}
void postMessageToWebView(String jsonString) {
String js = "(function() { " +
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
"} " +
"})();";
webView.evaluateJavascript(js, null);
}
void resolveRequest(String requestId, int status, String statusText,
Map<String, String> headers, String bodyBase64) {
PendingRequest pending = pendingRequests.remove(requestId);
if (pending == null) return;
byte[] bodyBytes = null;
if (bodyBase64 != null && !bodyBase64.equals("null")) {
try {
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
} catch (Exception e) {
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
}
}
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
String encoding = contentType.contains("text/") ? "UTF-8" : null;
InputStream body = bodyBytes != null
? new ByteArrayInputStream(bodyBytes)
: new ByteArrayInputStream(new byte[0]);
WebResourceResponse response = new WebResourceResponse(
contentType, encoding, status, statusText, headers, body
);
pending.resolve(response);
}
}
/**
* WebViewClient that intercepts all requests and forwards them to JS.
*/
private static class SandboxWebViewClient extends WebViewClient {
private final SandboxInstance sandbox;
private boolean bridgeInjected = false;
SandboxWebViewClient(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
// Only intercept requests to the sandbox domain.
if (!url.contains(".sandbox.native")) {
return null;
}
String requestId = UUID.randomUUID().toString();
// Create a pending request with a blocking latch.
PendingRequest pending = new PendingRequest();
sandbox.pendingRequests.put(requestId, pending);
// Rewrite URL to include the sandbox ID for the JS handler.
String path = request.getUrl().getPath();
if (path == null || path.isEmpty()) path = "/";
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
// Serialise the request.
JSObject serialisedRequest = new JSObject();
serialisedRequest.put("url", rewrittenURL);
serialisedRequest.put("method", request.getMethod());
JSObject headers = new JSObject();
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
headers.put(entry.getKey(), entry.getValue());
}
serialisedRequest.put("headers", headers);
serialisedRequest.put("body", JSONObject.NULL);
// Emit to JS.
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
// Block until JS responds. Each asset is fetched from a Blossom
// server over the network, so we need a generous timeout. The
// WebView IO thread pool has ~6 threads; if all are blocked,
// subsequent requests queue until a thread frees up.
WebResourceResponse response = pending.awaitResponse(60000);
if (response != null) {
return response;
}
// Timeout — return error response.
sandbox.pendingRequests.remove(requestId);
return new WebResourceResponse(
"text/plain", "UTF-8", 504,
"Gateway Timeout", new HashMap<>(),
new ByteArrayInputStream("Request timed out".getBytes())
);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (!bridgeInjected) {
bridgeInjected = true;
view.evaluateJavascript(getBridgeScript(), null);
}
// Remove the native spinner once the first page has finished
// loading (all initial resources resolved). This runs on the
// main thread, so the removal is safe.
sandbox.hideSpinner();
}
private String getBridgeScript() {
return "(function() {" +
"'use strict';" +
"var messageListeners = [];" +
"window.__sandboxBridge = {" +
" onMessage: function(data) {" +
" var event = {" +
" data: data," +
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
" source: window.parent," +
" type: 'message'" +
" };" +
" for (var i = 0; i < messageListeners.length; i++) {" +
" try { messageListeners[i](event); } catch(e) {}" +
" }" +
" }" +
"};" +
"var origAdd = window.addEventListener;" +
"window.addEventListener = function(type, fn, opts) {" +
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
" return origAdd.call(window, type, fn, opts);" +
"};" +
"var origRemove = window.removeEventListener;" +
"window.removeEventListener = function(type, fn, opts) {" +
" if (type === 'message') {" +
" var idx = messageListeners.indexOf(fn);" +
" if (idx !== -1) messageListeners.splice(idx, 1);" +
" }" +
" return origRemove.call(window, type, fn, opts);" +
"};" +
"if (!window.parent || window.parent === window) window.parent = {};" +
"window.parent.postMessage = function(data) {" +
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
" }" +
"};" +
"})();";
}
}
/**
* JavaScript interface exposed to the sandbox WebView.
*/
private static class SandboxBridge {
private final SandboxInstance sandbox;
SandboxBridge(SandboxInstance sandbox) {
this.sandbox = sandbox;
}
@JavascriptInterface
public void postMessage(String json) {
try {
JSONObject obj = new JSONObject(json);
JSObject jsObj = new JSObject();
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
String key = it.next();
jsObj.put(key, obj.get(key));
}
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
} catch (JSONException e) {
Log.w(TAG, "Failed to parse script message", e);
}
}
}
/**
* A pending request that blocks the WebViewClient IO thread until JS
* responds with the complete resource.
*/
private static class PendingRequest {
private volatile WebResourceResponse response;
private final CountDownLatch latch = new CountDownLatch(1);
void resolve(WebResourceResponse response) {
this.response = response;
latch.countDown();
}
WebResourceResponse awaitResponse(long timeoutMs) {
try {
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return response;
}
}
}
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android Auto Backup rules (Android 11 and below).
Ditto excludes WebView storage (Local Storage, IndexedDB, databases) and
any shared_prefs that hold sensitive credentials so they don't end up in
Google Drive backups. Keychain/KeyStore entries used by
capacitor-secure-storage-plugin are not backed up by default, so we don't
need to exclude those explicitly; but we also exclude the plugin's
SharedPreferences for defense in depth.
See: https://developer.android.com/guide/topics/data/autobackup
-->
<full-backup-content>
<!-- WebView: localStorage, IndexedDB, cookies, caches -->
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<!-- capacitor-secure-storage-plugin fallback SharedPreferences -->
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<!-- Capacitor preferences plugin — may contain app-level settings -->
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</full-backup-content>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 12+ data extraction rules.
Separate rules apply to cloud backups (Google Drive) and device-to-device
transfers. Both exclude WebView storage and sensitive SharedPreferences so
wallet credentials, login tokens, and cached private data don't leak.
See: https://developer.android.com/about/versions/12/backup-restore
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</device-transfer>
</data-extraction-rules>
+2 -11
View File
@@ -8,20 +8,11 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':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')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+4 -9
View File
@@ -5,6 +5,8 @@ const config: CapacitorConfig = {
appName: 'Ditto',
webDir: 'dist',
server: {
// Handle deep links from your domain
hostname: 'ditto.pub',
androidScheme: 'https',
iosScheme: 'https'
},
@@ -15,16 +17,9 @@ const config: CapacitorConfig = {
},
ios: {
backgroundColor: '#14161f',
contentInset: 'never',
contentInset: 'automatic',
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", "ios"] },
{ ignores: ["dist", "android"] },
{
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, interactive-widget=resizes-content" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
<!-- Open Graph -->
+2 -28
View File
@@ -15,11 +15,6 @@
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 */; };
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */; };
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40007000100000002 /* NostrPoller.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -33,12 +28,6 @@
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>"; };
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoNotificationPlugin.swift; sourceTree = "<group>"; };
B1A2C3D40007000100000002 /* NostrPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoller.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -74,17 +63,11 @@
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
B1A2C3D40004000100000002 /* App.entitlements */,
504EC3071FED79650016851F /* AppDelegate.swift */,
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
B1A2C3D40007000100000002 /* NostrPoller.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -162,7 +145,6 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -174,10 +156,6 @@
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */,
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -317,17 +295,15 @@
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.8.0;
MARKETING_VERSION = 2.5.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -341,17 +317,15 @@
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.8.0;
MARKETING_VERSION = 2.5.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
-11
View File
@@ -1,11 +0,0 @@
<?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>
+9 -80
View File
@@ -1,45 +1,36 @@
import UIKit
import Capacitor
import BackgroundTasks
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Register the background task handler for notification polling.
// Must happen before the app finishes launching.
DittoNotificationPlugin.registerBackgroundTask()
// Set ourselves as the notification center delegate so we can:
// 1. Show banners even when the app is in the foreground.
// 2. Handle notification taps to navigate the WebView.
UNUserNotificationCenter.current().delegate = self
// Register notification categories with summary formats for iOS grouping.
registerNotificationCategories()
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Trigger an immediate poll when returning to foreground to catch up
// on any notifications missed while backgrounded.
DittoNotificationPlugin.pollNow()
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
@@ -55,66 +46,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
// MARK: - UNUserNotificationCenterDelegate
/// Show notification banners even when the app is in the foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
/// Handle notification tap: navigate the Capacitor WebView to /notifications.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
let path = userInfo["url"] as? String ?? "/notifications"
// Navigate the Capacitor WebView to the notifications page.
DispatchQueue.main.async { [weak self] in
guard let rootVC = self?.window?.rootViewController as? DittoBridgeViewController else {
completionHandler()
return
}
let js = "window.location.pathname !== '\(path)' && (window.location.pathname = '\(path)');"
rootVC.webView?.evaluateJavaScript(js) { _, _ in }
}
completionHandler()
}
// MARK: - Notification Categories
/// Register notification categories with summary formats for native iOS
/// notification grouping. When multiple notifications share a thread
/// identifier, iOS automatically collapses them and uses the summary
/// format to describe the group.
private func registerNotificationCategories() {
let categories: [UNNotificationCategory] = [
makeCategory(id: NostrPoller.categoryReactions, summary: "%u more reactions"),
makeCategory(id: NostrPoller.categoryReposts, summary: "%u more reposts"),
makeCategory(id: NostrPoller.categoryZaps, summary: "%u more zaps"),
makeCategory(id: NostrPoller.categoryMentions, summary: "%u more mentions"),
makeCategory(id: NostrPoller.categoryComments, summary: "%u more comments"),
makeCategory(id: NostrPoller.categoryBadges, summary: "%u more badge awards"),
makeCategory(id: NostrPoller.categoryLetters, summary: "%u more letters"),
]
UNUserNotificationCenter.current().setNotificationCategories(Set(categories))
}
private func makeCategory(id: String, summary: String) -> UNNotificationCategory {
return UNNotificationCategory(
identifier: id,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: nil,
categorySummaryFormat: summary,
options: []
)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
@@ -1,9 +0,0 @@
import UIKit
import Capacitor
class DittoBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
webView?.allowsBackForwardNavigationGestures = true
}
}
-209
View File
@@ -1,209 +0,0 @@
import Foundation
import Capacitor
import BackgroundTasks
import UserNotifications
// MARK: - DittoNotificationPlugin
/// Capacitor plugin that bridges the JS notification configuration to the
/// native iOS background polling system.
///
/// Mirrors the Android `DittoNotificationPlugin.java` interface:
/// - Receives `userPubkey`, `relayUrls`, `enabledKinds`, `authors`, and
/// `notificationStyle` from the JS layer via `configure()`.
/// - Stores configuration in UserDefaults.
/// - Schedules / cancels a `BGAppRefreshTask` to periodically poll relays
/// and display local notifications via `NostrPoller`.
///
/// On iOS the "push" vs "persistent" distinction maps to:
/// - **"push"**: No background polling. Relies on Web Push (where supported)
/// or in-app polling when the app is open.
/// - **"persistent"**: Schedules `BGAppRefreshTask` for periodic relay polling.
/// iOS manages the interval (~15 min minimum, adaptive based on app usage).
@objc(DittoNotificationPlugin)
public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - Capacitor Bridging
public let identifier = "DittoNotificationPlugin"
public let jsName = "DittoNotification"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
]
// MARK: - Constants
static let bgTaskIdentifier = "pub.ditto.app.notification-refresh"
private static let prefsKey = "ditto_notification_config"
// MARK: - Plugin Methods
/// Called from JS: `DittoNotification.configure({ ... })`.
@objc func configure(_ call: CAPPluginCall) {
let userPubkey = call.getString("userPubkey")
let notificationStyle = call.getString("notificationStyle") ?? "push"
let relayUrls = call.getArray("relayUrls")?.compactMap { $0 as? String }
let enabledKinds = call.getArray("enabledKinds")?.compactMap { $0 as? Int }
let authors = call.getArray("authors")?.compactMap { $0 as? String }
let defaults = UserDefaults.standard
if let userPubkey, let relayUrls, !relayUrls.isEmpty {
// Save configuration.
defaults.set(userPubkey, forKey: "\(Self.prefsKey).userPubkey")
defaults.set(relayUrls, forKey: "\(Self.prefsKey).relayUrls")
defaults.set(notificationStyle, forKey: "\(Self.prefsKey).notificationStyle")
if let enabledKinds {
defaults.set(enabledKinds, forKey: "\(Self.prefsKey).enabledKinds")
}
if let authors, !authors.isEmpty {
defaults.set(authors, forKey: "\(Self.prefsKey).authors")
} else {
defaults.removeObject(forKey: "\(Self.prefsKey).authors")
}
let kindsStr = enabledKinds?.map(String.init).joined(separator: ",") ?? "none"
NSLog("[DittoNotification] Configured: pubkey=%@..., style=%@, relays=%d, kinds=%@",
String(userPubkey.prefix(8)), notificationStyle,
relayUrls.count,
kindsStr)
} else {
// Clear configuration (user logged out).
for suffix in ["userPubkey", "relayUrls", "notificationStyle", "enabledKinds", "authors"] {
defaults.removeObject(forKey: "\(Self.prefsKey).\(suffix)")
}
NSLog("[DittoNotification] Config cleared (user logged out)")
}
// Schedule or cancel background polling based on style + config.
let hasConfig = userPubkey != nil && relayUrls != nil && !(relayUrls?.isEmpty ?? true)
Self.manageBackgroundRefresh(style: notificationStyle, hasConfig: hasConfig)
call.resolve()
}
// MARK: - Background Task Management
/// Register the BGAppRefreshTask handler. Must be called from
/// `application(_:didFinishLaunchingWithOptions:)` before the app
/// finishes launching.
static func registerBackgroundTask() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: bgTaskIdentifier,
using: nil
) { task in
guard let refreshTask = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
Self.handleBackgroundRefresh(task: refreshTask)
}
NSLog("[DittoNotification] Registered BGAppRefreshTask: %@", bgTaskIdentifier)
}
/// Schedule or cancel the BGAppRefreshTask.
/// On iOS both "push" and "persistent" modes use BGAppRefreshTask
/// (there is no Web Push in WKWebView and no foreground service concept),
/// so we schedule whenever there is a valid config.
static func manageBackgroundRefresh(style: String, hasConfig: Bool) {
if hasConfig {
scheduleBackgroundRefresh()
} else {
cancelBackgroundRefresh()
}
}
/// Schedule the next background refresh. iOS decides the actual timing
/// (minimum ~15 minutes, adaptive based on user app usage patterns).
static func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: bgTaskIdentifier)
// Suggest earliest begin date of 8 minutes from now (iOS may defer).
request.earliestBeginDate = Date(timeIntervalSinceNow: 8 * 60)
do {
try BGTaskScheduler.shared.submit(request)
NSLog("[DittoNotification] Scheduled background refresh")
} catch {
NSLog("[DittoNotification] Failed to schedule background refresh: %@", error.localizedDescription)
}
}
private static func cancelBackgroundRefresh() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: bgTaskIdentifier)
NSLog("[DittoNotification] Cancelled background refresh")
}
/// Handle a BGAppRefreshTask: read config, poll, reschedule.
private static func handleBackgroundRefresh(task: BGAppRefreshTask) {
NSLog("[DittoNotification] Background refresh triggered")
// Read configuration from UserDefaults.
let defaults = UserDefaults.standard
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
!relayUrls.isEmpty else {
NSLog("[DittoNotification] No config, completing task")
task.setTaskCompleted(success: true)
return
}
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
guard !enabledKinds.isEmpty else {
NSLog("[DittoNotification] No enabled kinds, completing task")
task.setTaskCompleted(success: true)
return
}
// Schedule the next refresh before starting work (in case we're
// terminated mid-task, the next refresh is already queued).
scheduleBackgroundRefresh()
// Run the poll in a detached Task.
let pollTask = Task {
let poller = NostrPoller()
let count = await poller.poll(
userPubkey: userPubkey,
relayUrls: relayUrls,
enabledKinds: enabledKinds,
authors: authors
)
NSLog("[DittoNotification] Background poll complete: %d notifications", count)
task.setTaskCompleted(success: true)
}
// Handle task expiration (iOS is about to kill us).
task.expirationHandler = {
NSLog("[DittoNotification] Background task expired")
pollTask.cancel()
task.setTaskCompleted(success: false)
}
}
// MARK: - Immediate Poll
/// Trigger an immediate poll (e.g., when the app enters the foreground
/// after being backgrounded, to catch up on missed notifications).
static func pollNow() {
let defaults = UserDefaults.standard
guard let userPubkey = defaults.string(forKey: "\(prefsKey).userPubkey"),
let relayUrls = defaults.stringArray(forKey: "\(prefsKey).relayUrls"),
!relayUrls.isEmpty else { return }
let enabledKinds = defaults.array(forKey: "\(prefsKey).enabledKinds") as? [Int] ?? []
let authors = defaults.stringArray(forKey: "\(prefsKey).authors")
guard !enabledKinds.isEmpty else { return }
Task {
let poller = NostrPoller()
await poller.poll(
userPubkey: userPubkey,
relayUrls: relayUrls,
enabledKinds: enabledKinds,
authors: authors
)
}
}
}
-12
View File
@@ -49,19 +49,7 @@
<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/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>pub.ditto.app.notification-refresh</string>
</array>
</dict>
</plist>
-633
View File
@@ -1,633 +0,0 @@
import Foundation
import UserNotifications
// MARK: - NostrPoller
/// Polls Nostr relays for notification events and displays native iOS
/// notifications with author names, content previews, and iOS thread grouping.
///
/// Improvements over the Android implementation:
/// - Fetches kind 0 metadata so notifications show "Alice reacted" not "Someone reacted"
/// - Uses iOS thread identifiers for native notification grouping per category+post
/// - Caches author metadata in UserDefaults (24h TTL) to minimise relay queries
/// - Designed to complete within the ~30s BGAppRefreshTask budget
final class NostrPoller {
// MARK: - Constants
private static let prefsKey = "ditto_notifications"
private static let lastSeenKey = "nostr:notification-last-seen"
private static let metadataCacheKey = "nostr:author-metadata-cache"
private static let metadataTTL: TimeInterval = 24 * 60 * 60 // 24 hours
private static let fetchLimit = 5
private static let wsTimeout: TimeInterval = 10
private static let metadataFetchTimeout: TimeInterval = 5
// MARK: - Notification Categories (registered by AppDelegate)
/// Category identifiers used for UNNotificationCategory registration.
static let categoryReactions = "reactions"
static let categoryReposts = "reposts"
static let categoryZaps = "zaps"
static let categoryMentions = "mentions"
static let categoryComments = "comments"
static let categoryBadges = "badges"
static let categoryLetters = "letters"
// MARK: - Types
/// Minimal parsed Nostr event used during polling.
struct NostrEvent {
let id: String
let pubkey: String
let kind: Int
let createdAt: Int
let content: String
let tags: [[String]]
init?(json: [String: Any]) {
guard let id = json["id"] as? String,
let pubkey = json["pubkey"] as? String,
let kind = json["kind"] as? Int,
let createdAt = json["created_at"] as? Int else { return nil }
self.id = id
self.pubkey = pubkey
self.kind = kind
self.createdAt = createdAt
self.content = json["content"] as? String ?? ""
self.tags = (json["tags"] as? [[String]]) ?? []
}
}
/// Cached author display name.
private struct AuthorCache: Codable {
let name: String
let timestamp: TimeInterval
}
// MARK: - Public API
/// Run a single poll cycle: fetch events from a relay, resolve metadata,
/// and display notifications. Returns the number of notifications shown.
@discardableResult
func poll(
userPubkey: String,
relayUrls: [String],
enabledKinds: [Int],
authors: [String]?
) async -> Int {
guard !relayUrls.isEmpty, !enabledKinds.isEmpty else { return 0 }
let since = lastSeenTimestamp
let effectiveSince = since > 0 ? since : Int(Date().timeIntervalSince1970) - 300
if since == 0 {
setLastSeenTimestamp(effectiveSince)
}
// Try each relay in order until one succeeds.
for relayUrl in relayUrls {
guard let events = await fetchEvents(
relayUrl: relayUrl,
userPubkey: userPubkey,
enabledKinds: enabledKinds,
authors: authors,
since: effectiveSince
) else {
continue // Try next relay on failure.
}
// Deduplicate + filter self-interactions.
var seenIds = Set<String>()
let filtered = events.filter { ev in
guard ev.pubkey != userPubkey, !seenIds.contains(ev.id) else { return false }
seenIds.insert(ev.id)
return true
}
guard !filtered.isEmpty else {
// Successful fetch but nothing new update timestamp and return.
return 0
}
// Verify referenced events for reactions/reposts/zaps.
let notifiable = await verifyReferencedEvents(
events: filtered,
userPubkey: userPubkey,
relayUrl: relayUrl
)
// Update last-seen to newest event in the full filtered set (not
// just notifiable) so we don't re-fetch already-seen events.
let newestTs = filtered.map(\.createdAt).max() ?? effectiveSince
if newestTs > lastSeenTimestamp {
setLastSeenTimestamp(newestTs)
}
guard !notifiable.isEmpty else { return 0 }
// Fetch author metadata for unique pubkeys.
let pubkeys = Array(Set(notifiable.map(\.pubkey)))
let authorNames = await resolveAuthorNames(pubkeys: pubkeys, relayUrl: relayUrl)
// Display notifications.
await displayNotifications(events: notifiable, authorNames: authorNames)
return notifiable.count
}
return 0 // All relays failed.
}
// MARK: - Relay Communication
/// Fetch notification events from a single relay. Returns nil on failure.
private func fetchEvents(
relayUrl: String,
userPubkey: String,
enabledKinds: [Int],
authors: [String]?,
since: Int
) async -> [NostrEvent]? {
guard let url = URL(string: relayUrl) else { return nil }
var filter: [String: Any] = [
"kinds": enabledKinds,
"#p": [userPubkey],
"since": since + 1,
"limit": Self.fetchLimit,
]
if let authors, !authors.isEmpty {
filter["authors"] = authors
}
return await relayQuery(url: url, filters: [filter])
}
/// Fetch events by IDs from a relay for referenced-event verification.
private func fetchEventsByIds(ids: [String], relayUrl: String) async -> [String: NostrEvent] {
guard !ids.isEmpty, let url = URL(string: relayUrl) else { return [:] }
let filter: [String: Any] = [
"ids": ids,
"limit": ids.count,
]
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
return [:]
}
var map = [String: NostrEvent]()
for ev in events {
map[ev.id] = ev
}
return map
}
/// Fetch kind 0 metadata events for a set of pubkeys.
private func fetchMetadata(pubkeys: [String], relayUrl: String) async -> [String: NostrEvent] {
guard !pubkeys.isEmpty, let url = URL(string: relayUrl) else { return [:] }
let filter: [String: Any] = [
"kinds": [0],
"authors": pubkeys,
"limit": pubkeys.count,
]
guard let events = await relayQuery(url: url, filters: [filter], timeout: Self.metadataFetchTimeout) else {
return [:]
}
var map = [String: NostrEvent]()
for ev in events {
// Keep only the newest kind 0 per pubkey.
if let existing = map[ev.pubkey], existing.createdAt > ev.createdAt {
continue
}
map[ev.pubkey] = ev
}
return map
}
/// Low-level relay query: open WebSocket, send REQ, collect events until
/// EOSE, close. Returns nil on connection/timeout failure.
private func relayQuery(
url: URL,
filters: [[String: Any]],
timeout: TimeInterval = wsTimeout
) async -> [NostrEvent]? {
await withCheckedContinuation { continuation in
var events = [NostrEvent]()
var resumed = false
let subId = "ditto-\(UInt64.random(in: 0...UInt64.max))"
let session = URLSession(configuration: .default)
let task = session.webSocketTask(with: url)
task.resume()
// Build REQ message: ["REQ", subId, filter1, filter2, ...]
var reqArray: [Any] = ["REQ", subId]
reqArray.append(contentsOf: filters)
guard let reqData = try? JSONSerialization.data(withJSONObject: reqArray),
let reqStr = String(data: reqData, encoding: .utf8) else {
continuation.resume(returning: nil)
return
}
// Timeout guard.
let timeoutWork = DispatchWorkItem { [weak task] in
guard !resumed else { return }
resumed = true
task?.cancel(with: .goingAway, reason: nil)
session.invalidateAndCancel()
continuation.resume(returning: events.isEmpty ? nil : events)
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
func finish(result: [NostrEvent]?) {
timeoutWork.cancel()
guard !resumed else { return }
resumed = true
// Send CLOSE and disconnect.
if let closeData = try? JSONSerialization.data(withJSONObject: ["CLOSE", subId]),
let closeStr = String(data: closeData, encoding: .utf8) {
task.send(.string(closeStr)) { _ in }
}
task.cancel(with: .normalClosure, reason: nil)
session.invalidateAndCancel()
continuation.resume(returning: result)
}
func receiveNext() {
task.receive { result in
switch result {
case .success(.string(let text)):
guard let data = text.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any],
let type = arr.first as? String else {
receiveNext()
return
}
if type == "EVENT", arr.count >= 3,
let evJson = arr[2] as? [String: Any],
let ev = NostrEvent(json: evJson) {
events.append(ev)
receiveNext()
} else if type == "EOSE" || type == "CLOSED" {
finish(result: events)
} else {
receiveNext()
}
case .failure:
finish(result: nil)
default:
receiveNext()
}
}
}
task.send(.string(reqStr)) { error in
if error != nil {
finish(result: nil)
} else {
receiveNext()
}
}
}
}
// MARK: - Event Verification
/// For reactions (7), reposts (6, 16), and zaps (9735), verify that the
/// referenced event was authored by the current user. Events that pass
/// verification or don't need it are returned.
private func verifyReferencedEvents(
events: [NostrEvent],
userPubkey: String,
relayUrl: String
) async -> [NostrEvent] {
let needsVerification: Set<Int> = [7, 6, 16, 9735]
// Collect referenced IDs that need verification.
var refIdsNeeded = Set<String>()
for ev in events where needsVerification.contains(ev.kind) {
if let refId = referencedEventId(from: ev) {
refIdsNeeded.insert(refId)
}
}
let refMap: [String: NostrEvent]
if !refIdsNeeded.isEmpty {
refMap = await fetchEventsByIds(ids: Array(refIdsNeeded), relayUrl: relayUrl)
} else {
refMap = [:]
}
return events.filter { ev in
guard needsVerification.contains(ev.kind) else { return true }
// Zaps with #p tag targeting the user are valid (profile zaps have no e tag).
if ev.kind == 9735 {
return true
}
guard let refId = referencedEventId(from: ev) else { return false }
guard let refEvent = refMap[refId] else {
// Couldn't fetch keep the notification rather than silently dropping it.
return true
}
return refEvent.pubkey == userPubkey
}
}
/// Returns the last `e` tag value from an event's tags.
private func referencedEventId(from event: NostrEvent) -> String? {
event.tags.last(where: { $0.first == "e" && $0.count > 1 })?[1]
}
// MARK: - Author Metadata Resolution
/// Resolve display names for a set of pubkeys, using cache where possible.
private func resolveAuthorNames(pubkeys: [String], relayUrl: String) async -> [String: String] {
var result = [String: String]()
var uncached = [String]()
let cache = loadMetadataCache()
let now = Date().timeIntervalSince1970
for pk in pubkeys {
if let cached = cache[pk], now - cached.timestamp < Self.metadataTTL {
result[pk] = cached.name
} else {
uncached.append(pk)
}
}
// Fetch uncached metadata from the relay.
if !uncached.isEmpty {
let metadataEvents = await fetchMetadata(pubkeys: uncached, relayUrl: relayUrl)
var updatedCache = cache
for pk in uncached {
if let ev = metadataEvents[pk], let name = parseDisplayName(from: ev) {
result[pk] = name
updatedCache[pk] = AuthorCache(name: name, timestamp: now)
} else {
// Fall back to truncated npub-style identifier.
let fallback = formatPubkey(pk)
result[pk] = fallback
// Don't cache failures retry next time.
}
}
saveMetadataCache(updatedCache)
}
return result
}
/// Parse display_name or name from a kind 0 event's content JSON.
private func parseDisplayName(from event: NostrEvent) -> String? {
guard let data = event.content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
// Prefer display_name, fall back to name.
if let displayName = json["display_name"] as? String, !displayName.isEmpty {
return displayName
}
if let name = json["name"] as? String, !name.isEmpty {
return name
}
return nil
}
/// Format a hex pubkey as a short identifier: first 8 + "..." + last 4.
private func formatPubkey(_ pubkey: String) -> String {
guard pubkey.count >= 12 else { return pubkey }
let start = pubkey.prefix(8)
let end = pubkey.suffix(4)
return "\(start)...\(end)"
}
// MARK: - Metadata Cache (UserDefaults)
private func loadMetadataCache() -> [String: AuthorCache] {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey: Self.metadataCacheKey),
let cache = try? JSONDecoder().decode([String: AuthorCache].self, from: data) else {
return [:]
}
return cache
}
private func saveMetadataCache(_ cache: [String: AuthorCache]) {
guard let data = try? JSONEncoder().encode(cache) else { return }
UserDefaults.standard.set(data, forKey: Self.metadataCacheKey)
}
// MARK: - Notification Display
/// Display native iOS notifications for a batch of verified events.
private func displayNotifications(events: [NostrEvent], authorNames: [String: String]) async {
let center = UNUserNotificationCenter.current()
for event in events {
let authorName = authorNames[event.pubkey] ?? formatPubkey(event.pubkey)
let (title, body, categoryId, threadId) = notificationContent(
event: event,
authorName: authorName
)
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.categoryIdentifier = categoryId
content.threadIdentifier = threadId
content.userInfo = ["url": "/notifications"]
let identifier = "ditto-\(event.id.prefix(16))"
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately.
)
try? await center.add(request)
}
}
/// Build notification title, body, category ID, and thread identifier for an event.
private func notificationContent(
event: NostrEvent,
authorName: String
) -> (title: String, body: String, categoryId: String, threadId: String) {
let refId = referencedEventId(from: event) ?? ""
switch event.kind {
case 7:
// Reaction show the reaction content (emoji) if available.
let reaction = event.content.isEmpty || event.content == "+" ? "❤️" : event.content
return (
"\(authorName) reacted \(reaction)",
"Reacted to your post",
Self.categoryReactions,
"reactions:\(refId)"
)
case 6, 16:
return (
"\(authorName) reposted your note",
"",
Self.categoryReposts,
"reposts:\(refId)"
)
case 9735:
let sats = zapAmount(from: event)
if sats > 0 {
return (
"\(formatSats(sats)) sats from \(authorName)",
"You received a zap",
Self.categoryZaps,
"zaps"
)
}
return (
"\(authorName) zapped you",
"",
Self.categoryZaps,
"zaps"
)
case 1:
let hasETag = event.tags.contains(where: { $0.first == "e" })
let preview = contentPreview(event.content, maxLength: 120)
if hasETag {
return (
"\(authorName) replied to you",
preview,
Self.categoryMentions,
"mentions"
)
}
return (
"\(authorName) mentioned you",
preview,
Self.categoryMentions,
"mentions"
)
case 1111, 1222, 1244:
let preview = contentPreview(event.content, maxLength: 120)
// Check if this is a reply to another comment (k tag == "1111").
let isReply = event.tags.contains(where: { $0.first == "k" && $0.count > 1 && $0[1] == "1111" })
let action = isReply ? "replied to your comment" : "commented on your post"
return (
"\(authorName) \(action)",
preview,
Self.categoryComments,
"comments:\(refId)"
)
case 8:
return (
"\(authorName) awarded you a badge",
"You received a new badge",
Self.categoryBadges,
"badges"
)
case 8211:
return (
"\(authorName) sent you a letter",
"You have a new letter waiting for you",
Self.categoryLetters,
"letters"
)
default:
return (
"\(authorName) interacted with you",
"",
Self.categoryMentions,
"mentions"
)
}
}
/// Truncate content for notification body preview.
private func contentPreview(_ content: String, maxLength: Int) -> String {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
// Replace newlines with spaces for a single-line preview.
let singleLine = trimmed.replacingOccurrences(
of: "\\s*\\n+\\s*",
with: " ",
options: .regularExpression
)
guard singleLine.count > maxLength else { return singleLine }
return String(singleLine.prefix(maxLength)) + ""
}
// MARK: - Zap Amount Extraction
/// Extract zap amount in sats from a kind 9735 zap receipt event.
/// Checks the "amount" tag first (millisats), then falls back to
/// parsing the "description" tag's zap request JSON.
private func zapAmount(from event: NostrEvent) -> Int {
// Check for direct "amount" tag (value in millisats).
for tag in event.tags where tag.first == "amount" && tag.count > 1 {
if let msats = Int(tag[1]), msats > 0 {
return msats / 1000
}
}
// Fall back to "description" tag (zap request JSON) -> amount tag.
for tag in event.tags where tag.first == "description" && tag.count > 1 {
guard let data = tag[1].data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let reqTags = json["tags"] as? [[String]] else { continue }
for reqTag in reqTags where reqTag.first == "amount" && reqTag.count > 1 {
if let msats = Int(reqTag[1]), msats > 0 {
return msats / 1000
}
}
}
return 0
}
/// Format sats for compact display: 500 -> "500", 1500 -> "1.5K", 1000000 -> "1M".
private func formatSats(_ sats: Int) -> String {
if sats >= 1_000_000 {
let val = Double(sats) / 1_000_000.0
if val == val.rounded(.down) {
return "\(Int(val))M"
}
return String(format: "%.1fM", val).replacingOccurrences(of: ".0M", with: "M")
} else if sats >= 1_000 {
let val = Double(sats) / 1_000.0
if val == val.rounded(.down) {
return "\(Int(val))K"
}
return String(format: "%.1fK", val).replacingOccurrences(of: ".0K", with: "K")
}
return "\(sats)"
}
// MARK: - Last-Seen Timestamp
var lastSeenTimestamp: Int {
UserDefaults.standard.integer(forKey: Self.lastSeenKey)
}
func setLastSeenTimestamp(_ ts: Int) {
UserDefaults.standard.set(ts, forKey: Self.lastSeenKey)
}
}
-541
View File
@@ -1,541 +0,0 @@
import Foundation
import Capacitor
import WebKit
// MARK: - Plugin
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
///
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
/// and forwarded to the JS layer as fetch events the same protocol
/// iframe.diy uses. This lets the existing React code serve files identically.
@objc(SandboxPlugin)
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "SandboxPlugin"
public let jsName = "SandboxPlugin"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
]
/// Active sandbox instances, keyed by sandbox ID.
private var sandboxes: [String: SandboxInstance] = [:]
// MARK: - Plugin Methods
@objc func create(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
if sandboxes[sandboxId] != nil {
call.reject("Sandbox already exists: \(sandboxId)")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
let sandbox = SandboxInstance(
id: sandboxId,
frame: webViewFrame,
plugin: self
)
self.sandboxes[sandboxId] = sandbox
// Add the container (WebView + spinner overlay) on top of
// the Capacitor WebView.
if let bridge = self.bridge,
let webView = bridge.webView {
webView.superview?.addSubview(sandbox.containerView)
}
call.resolve()
}
}
@objc func navigate(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.navigateToApp()
call.resolve()
}
}
@objc func updateFrame(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let frame = call.getObject("frame"),
let x = frame["x"] as? Double,
let y = frame["y"] as? Double,
let width = frame["width"] as? Double,
let height = frame["height"] as? Double else {
call.reject("Missing or invalid parameter: frame")
return
}
DispatchQueue.main.async { [weak self] in
guard let sandbox = self?.sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
call.resolve()
}
}
@objc func respondToFetch(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let requestId = call.getString("requestId") else {
call.reject("Missing required parameter: requestId")
return
}
guard let response = call.getObject("response") else {
call.reject("Missing required parameter: response")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
sandbox.schemeHandler.resolveRequest(
requestId: requestId,
status: response["status"] as? Int ?? 200,
statusText: response["statusText"] as? String ?? "OK",
headers: response["headers"] as? [String: String] ?? [:],
bodyBase64: response["body"] as? String
)
call.resolve()
}
@objc func postMessage(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
guard let message = call.getObject("message") else {
call.reject("Missing required parameter: message")
return
}
guard let sandbox = sandboxes[sandboxId] else {
call.reject("Sandbox not found: \(sandboxId)")
return
}
DispatchQueue.main.async {
sandbox.postMessageToWebView(message)
}
call.resolve()
}
@objc func destroy(_ call: CAPPluginCall) {
guard let sandboxId = call.getString("id") else {
call.reject("Missing required parameter: id")
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
sandbox.containerView.removeFromSuperview()
sandbox.schemeHandler.cancelAll()
}
call.resolve()
}
}
// MARK: - Event Forwarding
/// Forward a fetch request from the native WebView to JS.
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
notifyListeners("fetch", data: [
"id": sandboxId,
"requestId": requestId,
"request": request,
])
}
/// Forward a script message from the sandbox to JS.
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
notifyListeners("scriptMessage", data: [
"id": sandboxId,
"message": message,
])
}
}
// MARK: - SandboxInstance
/// Manages a single sandboxed WKWebView instance.
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let id: String
let webView: WKWebView
let schemeHandler: SandboxSchemeHandler
private weak var plugin: SandboxPlugin?
private let customScheme: String
/// Container view that holds the WebView and spinner overlay.
let containerView: UIView
/// Native spinner overlay, removed when the first page finishes loading.
private var spinnerOverlay: UIView?
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
self.id = id
self.plugin = plugin
// Each sandbox gets a unique custom URL scheme so that WKWebView
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
self.customScheme = "sbx-\(id)"
self.schemeHandler = SandboxSchemeHandler(
sandboxId: id,
scheme: self.customScheme,
plugin: plugin
)
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
// Add a script message handler for communication from injected scripts.
let userContentController = WKUserContentController()
// Inject a bridge script that:
// 1. Provides window.parent.postMessage()-like functionality
// 2. Routes messages through the native bridge
let bridgeScript = WKUserScript(
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
userContentController.addUserScript(bridgeScript)
config.userContentController = userContentController
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.defaultWebpagePreferences.allowsContentJavaScript = true
// Container view that holds the WebView + spinner overlay.
self.containerView = UIView(frame: frame)
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.webView.isOpaque = false
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
self.webView.scrollView.bounces = false
self.containerView.addSubview(self.webView)
// Dark overlay behind the spinner.
let overlay = UIView(frame: containerView.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
self.containerView.addSubview(overlay)
// Native spinner uses UIActivityIndicatorView which animates on
// the render thread independently of JS/main-thread work.
let spinner = UIActivityIndicatorView(style: .medium)
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
spinner.translatesAutoresizingMaskIntoConstraints = false
spinner.startAnimating()
overlay.addSubview(spinner)
NSLayoutConstraint.activate([
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
])
self.spinnerOverlay = overlay
super.init()
// Register the message handler and navigation delegate after super.init().
userContentController.add(self, name: "sandboxBridge")
self.webView.navigationDelegate = self
}
/// Navigate the WebView to the sandbox's entry point.
func navigateToApp() {
let initialURL = URL(string: "\(customScheme)://app/index.html")!
webView.load(URLRequest(url: initialURL))
}
/// Remove the native loading overlay. Safe to call multiple times.
func hideSpinner() {
spinnerOverlay?.removeFromSuperview()
spinnerOverlay = nil
}
/// Post a JSON-RPC message to injected scripts inside the WebView.
func postMessageToWebView(_ message: [String: Any]) {
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return
}
let js = """
(function() {
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
window.__sandboxBridge.onMessage(\(jsonString));
}
})();
"""
webView.evaluateJavaScript(js, completionHandler: nil)
}
// MARK: - WKScriptMessageHandler
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "sandboxBridge",
let body = message.body as? [String: Any] else {
return
}
plugin?.emitScriptMessage(sandboxId: id, message: body)
}
// MARK: - WKNavigationDelegate
/// Remove the spinner overlay once the first page finishes loading.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
hideSpinner()
}
// MARK: - Bridge Script
/// JavaScript injected at document start that provides:
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
/// - `window.addEventListener("message", ...)` support for injected scripts
private static func bridgeScript(scheme: String) -> String {
return """
(function() {
'use strict';
// Message listeners registered by injected scripts.
var messageListeners = [];
// Bridge object for native communication.
window.__sandboxBridge = {
onMessage: function(data) {
// Dispatch to all registered message listeners.
var event = {
data: data,
origin: '\(scheme)://app',
source: window.parent,
type: 'message'
};
for (var i = 0; i < messageListeners.length; i++) {
try {
messageListeners[i](event);
} catch (e) {
console.error('[SandboxBridge] Listener error:', e);
}
}
}
};
// Override addEventListener to capture "message" listeners.
var originalAddEventListener = window.addEventListener;
window.addEventListener = function(type, listener, options) {
if (type === 'message' && typeof listener === 'function') {
messageListeners.push(listener);
}
return originalAddEventListener.call(window, type, listener, options);
};
var originalRemoveEventListener = window.removeEventListener;
window.removeEventListener = function(type, listener, options) {
if (type === 'message') {
var idx = messageListeners.indexOf(listener);
if (idx !== -1) messageListeners.splice(idx, 1);
}
return originalRemoveEventListener.call(window, type, listener, options);
};
// Emulate window.parent.postMessage for scripts that use it
// (e.g. the webxdc bridge script, preview injected script).
if (!window.parent || window.parent === window) {
window.parent = {};
}
window.parent.postMessage = function(data, targetOrigin, transfer) {
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
try {
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
} catch (e) {
console.error('[SandboxBridge] postMessage failed:', e);
}
}
};
})();
""";
}
}
// MARK: - SandboxSchemeHandler
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
/// URL scheme and forwards them to the JS layer as fetch events.
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
private let sandboxId: String
private let scheme: String
private weak var plugin: SandboxPlugin?
/// Pending scheme tasks waiting for a response from JS.
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
private var pendingTasks: [String: WKURLSchemeTask] = [:]
private let lock = NSLock()
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
self.sandboxId = sandboxId
self.scheme = scheme
self.plugin = plugin
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let request = urlSchemeTask.request
guard let url = request.url else {
urlSchemeTask.didFailWithError(NSError(
domain: "SandboxPlugin", code: -1,
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
))
return
}
let requestId = UUID().uuidString
lock.lock()
pendingTasks[requestId] = urlSchemeTask
lock.unlock()
// Serialise the request for the fetch event.
// Rewrite the URL so it looks like a normal HTTP URL to the parent
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
// The JS side only cares about the pathname.
var headers: [String: String] = [:]
if let allHeaders = request.allHTTPHeaderFields {
headers = allHeaders
}
var bodyBase64: String? = nil
if let bodyData = request.httpBody {
bodyBase64 = bodyData.base64EncodedString()
}
let path = url.path.isEmpty ? "/" : url.path
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
let serialisedRequest: [String: Any] = [
"url": rewrittenURL,
"method": request.httpMethod ?? "GET",
"headers": headers,
"body": bodyBase64 as Any,
]
plugin?.emitFetchRequest(
sandboxId: sandboxId,
requestId: requestId,
request: serialisedRequest
)
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// Remove the task from pending JS response will be ignored if it arrives later.
lock.lock()
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
if let key = removed?.key {
pendingTasks.removeValue(forKey: key)
}
lock.unlock()
}
/// Called by the plugin when JS responds to a fetch request.
func resolveRequest(
requestId: String,
status: Int,
statusText: String,
headers: [String: String],
bodyBase64: String?
) {
lock.lock()
guard let task = pendingTasks.removeValue(forKey: requestId) else {
lock.unlock()
return
}
lock.unlock()
// Decode the base64 body.
var bodyData: Data? = nil
if let b64 = bodyBase64 {
bodyData = Data(base64Encoded: b64)
}
// Build the response.
// Use the task's original URL for the response.
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
let response = HTTPURLResponse(
url: responseURL,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
DispatchQueue.main.async {
task.didReceive(response)
if let data = bodyData {
task.didReceive(data)
}
task.didFinish()
}
}
/// Cancel all pending tasks (called on destroy).
func cancelAll() {
lock.lock()
let tasks = pendingTasks
pendingTasks.removeAll()
lock.unlock()
for (_, task) in tasks {
task.didFailWithError(NSError(
domain: "SandboxPlugin", code: -999,
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
))
}
}
}
+2 -8
View File
@@ -14,12 +14,9 @@ let package = Package(
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
],
targets: [
.target(
@@ -29,12 +26,9 @@ let package = Package(
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
]
)
]
+195 -377
View File
@@ -1,21 +1,19 @@
{
"name": "ditto",
"version": "2.7.1",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.7.1",
"version": "2.5.1",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/haptics": "^8.0.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-autofill-save-password": "^8.0.22",
"@capacitor/status-bar": "^8.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -61,7 +59,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.1",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -93,11 +91,10 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"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",
@@ -110,7 +107,7 @@
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^1.8.0",
"lucide-react": "^0.462.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -219,66 +216,19 @@
}
},
"node_modules/@babel/generator": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz",
"integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==",
"version": "7.27.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^8.0.0-rc.3",
"@babel/types": "^8.0.0-rc.3",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"@types/jsesc": "^2.5.0",
"@babel/parser": "^7.27.5",
"@babel/types": "^7.27.3",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.0-rc.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/generator/node_modules/@babel/types": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0-rc.3",
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -419,15 +369,6 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/haptics": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz",
"integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/ios": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.2.0.tgz",
@@ -438,15 +379,6 @@
"@capacitor/core": "^8.2.0"
}
},
"node_modules/@capacitor/keyboard": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
"integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/local-notifications": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.0.1.tgz",
@@ -465,21 +397,21 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/status-bar": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"license": "ISC"
},
"node_modules/@capgo/capacitor-autofill-save-password": {
"version": "8.0.22",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-autofill-save-password/-/capacitor-autofill-save-password-8.0.22.tgz",
"integrity": "sha512-l6RvtTgdZWDx5fu74QcdV0NLioKmI4PwzCnscpl00ZjxHjecR/yVoB5ufsOYLAY2qyLP3jx9PUpFvEo2rPNHPA==",
"license": "MPL-2.0",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
@@ -1857,23 +1789,17 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -1885,6 +1811,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -1892,9 +1827,9 @@
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -2454,22 +2389,20 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@noble/ciphers": {
@@ -2594,9 +2527,9 @@
"license": "MIT"
},
"node_modules/@nostrify/react": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
"dependencies": {
"@nostrify/nostrify": "0.51.1",
"@nostrify/types": "0.36.9"
@@ -2623,9 +2556,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.123.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
"integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -5458,9 +5391,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"cpu": [
"arm64"
],
@@ -5475,9 +5408,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"cpu": [
"arm64"
],
@@ -5492,9 +5425,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz",
"integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"cpu": [
"x64"
],
@@ -5509,9 +5442,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz",
"integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"cpu": [
"x64"
],
@@ -5526,9 +5459,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz",
"integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"cpu": [
"arm"
],
@@ -5543,9 +5476,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"cpu": [
"arm64"
],
@@ -5560,9 +5493,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz",
"integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"cpu": [
"arm64"
],
@@ -5577,9 +5510,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"cpu": [
"ppc64"
],
@@ -5594,9 +5527,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"cpu": [
"s390x"
],
@@ -5611,9 +5544,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz",
"integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"cpu": [
"x64"
],
@@ -5628,9 +5561,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz",
"integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"cpu": [
"x64"
],
@@ -5645,9 +5578,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz",
"integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"cpu": [
"arm64"
],
@@ -5662,9 +5595,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz",
"integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"cpu": [
"wasm32"
],
@@ -5672,18 +5605,16 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.1",
"@emnapi/runtime": "1.9.1",
"@napi-rs/wasm-runtime": "^1.1.2"
"@napi-rs/wasm-runtime": "^1.1.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz",
"integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"cpu": [
"arm64"
],
@@ -5698,9 +5629,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz",
"integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"cpu": [
"x64"
],
@@ -5722,9 +5653,9 @@
"license": "MIT"
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
@@ -6598,12 +6529,6 @@
"@types/unist": "*"
}
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -6995,33 +6920,30 @@
"license": "ISC"
},
"node_modules/@unhead/addons": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.1.13.tgz",
"integrity": "sha512-xiM5ERU68FEuiBCCiPZ1EDkja+kH4hKKot/7dNJufneACtGoAFWnKUcmj/iB9BKjVwgBBF3sFYO3qXjkNFXWxA==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.0.10.tgz",
"integrity": "sha512-9+w/m+X5e7CDKXKGTym1N4MpBjrRC89cfl95RDgKwBcFJfQ3pZu50llIjx/j462VqtrNMXddBKcUnfWvQyapuw==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.3.0",
"@rollup/pluginutils": "^5.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"ufo": "^1.6.3",
"unplugin": "^3.0.0",
"unplugin-ast": "^0.16.0"
"magic-string": "^0.30.17",
"mlly": "^1.7.4",
"ufo": "^1.6.1",
"unplugin": "^2.3.4",
"unplugin-ast": "^0.15.0"
},
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
},
"peerDependencies": {
"unhead": "^2.1.13"
}
},
"node_modules/@unhead/react": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.13.tgz",
"integrity": "sha512-gC48tNJ0UtbithkiKCc2WUlxbVVk5o171EtruS2w2hQUblfYFHzCPu2hljjT1e0tUHXXqN8EMv7mpxHddMB2sg==",
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz",
"integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==",
"license": "MIT",
"dependencies": {
"unhead": "2.1.13"
"unhead": "2.1.12"
},
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
@@ -7295,9 +7217,9 @@
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -7432,68 +7354,21 @@
}
},
"node_modules/ast-kit": {
"version": "3.0.0-beta.1",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz",
"integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.0.tgz",
"integrity": "sha512-ROM2LlXbZBZVk97crfw8PGDOBzzsJvN2uJCmwswvPUNyfH14eg90mSN3xNqsri1JS1G9cz0VzeDUhxJkTrr4Ew==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^8.0.0-beta.4",
"estree-walker": "^3.0.3",
"@babel/parser": "^7.27.3",
"pathe": "^2.0.3"
},
"engines": {
"node": ">=20.19.0"
"node": ">=20.18.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ast-kit/node_modules/@babel/helper-string-parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/ast-kit/node_modules/@babel/parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.0-rc.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/ast-kit/node_modules/@babel/types": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0-rc.3",
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -7795,15 +7670,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/capacitor-secure-storage-plugin": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
@@ -10122,12 +9988,12 @@
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"version": "0.462.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/lz-string": {
@@ -10150,15 +10016,15 @@
}
},
"node_modules/magic-string-ast": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-0.9.1.tgz",
"integrity": "sha512-18dv2ZlSSgJ/jDWlZGKfnDJx56ilNlYq9F7NnwuWTErsmYmqJ2TWE4l1o2zlUHBYUGBy3tIhPCC1gxq8M5HkMA==",
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.19"
"magic-string": "^0.30.17"
},
"engines": {
"node": ">=20.19.0"
"node": ">=20.18.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
@@ -11141,15 +11007,15 @@
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
"acorn": "^8.14.0",
"pathe": "^2.0.1",
"pkg-types": "^1.3.0",
"ufo": "^1.5.4"
}
},
"node_modules/ms": {
@@ -12723,14 +12589,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
"integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.123.0",
"@rolldown/pluginutils": "1.0.0-rc.13"
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -12739,27 +12605,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.13",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.13",
"@rolldown/binding-darwin-x64": "1.0.0-rc.13",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.13",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.13",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.13",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.13",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13"
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.13",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"dev": true,
"license": "MIT"
},
@@ -13813,9 +13679,9 @@
}
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
"license": "MIT"
},
"node_modules/undici-types": {
@@ -13826,9 +13692,9 @@
"license": "MIT"
},
"node_modules/unhead": {
"version": "2.1.13",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.13.tgz",
"integrity": "sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==",
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
"license": "MIT",
"dependencies": {
"hookable": "^6.0.1"
@@ -13949,85 +13815,37 @@
}
},
"node_modules/unplugin": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.3",
"acorn": "^8.14.1",
"picomatch": "^4.0.2",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": ">=18.12.0"
}
},
"node_modules/unplugin-ast": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.16.0.tgz",
"integrity": "sha512-1ow2FlRznoSKE7Fjk2bSxqDsvHyj/O876RqsNlipsM6A+I91t7Mi+jG7tCNNcl3vZx14z4pGXBLSl8KOPrMuFQ==",
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.15.0.tgz",
"integrity": "sha512-3ReKQUmmYEcNhjoyiwfFuaJU0jkZNcNk8+iLdLVWk73iojVjJLiF/QhnpAFf3O7CJd6bqhWBzNyQ68Udp2fi5Q==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^8.0.0-beta.4",
"@babel/parser": "^8.0.0-beta.4",
"@babel/types": "^8.0.0-beta.4",
"ast-kit": "^3.0.0-beta.1",
"magic-string-ast": "^1.0.3",
"unplugin": "^3.0.0"
"@babel/generator": "^7.27.1",
"ast-kit": "^2.0.0",
"magic-string-ast": "^0.9.1",
"unplugin": "^2.3.2"
},
"engines": {
"node": ">=20.19.0"
"node": ">=20.18.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/unplugin-ast/node_modules/@babel/helper-string-parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-ast/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-ast/node_modules/@babel/parser": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.0-rc.3"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin-ast/node_modules/@babel/types": {
"version": "8.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0-rc.3",
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/unplugin/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -14219,16 +14037,16 @@
}
},
"node_modules/vite": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.13",
"rolldown": "1.0.0-rc.12",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -14246,7 +14064,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0 || ^0.28.0",
"esbuild": "^0.27.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
@@ -14835,9 +14653,9 @@
}
},
"node_modules/vite-node/node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15536,9 +15354,9 @@
}
},
"node_modules/vitest/node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"dependencies": {
+6 -10
View File
@@ -1,13 +1,12 @@
{
"name": "ditto",
"private": true,
"version": "2.8.0",
"version": "2.5.1",
"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"
},
@@ -18,11 +17,9 @@
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/haptics": "^8.0.2",
"@capacitor/keyboard": "^8.0.3",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capgo/capacitor-autofill-save-password": "^8.0.22",
"@capacitor/status-bar": "^8.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -68,7 +65,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.1",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -100,11 +97,10 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"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",
@@ -117,7 +113,7 @@
"html-to-image": "^1.11.13",
"idb": "^8.0.3",
"input-otp": "^1.2.4",
"lucide-react": "^1.8.0",
"lucide-react": "^0.462.0",
"nostr-tools": "^2.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
@@ -1,7 +0,0 @@
{
"webcredentials": {
"apps": [
"GZLTTH5DLM.pub.ditto.app"
]
}
}
+7 -14
View File
@@ -1,15 +1,8 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "pub.ditto.app",
"sha256_cert_fingerprints": [
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
]
}
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "pub.ditto.app",
"sha256_cert_fingerprints": ["7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71"]
}
]
}]
-170
View File
@@ -1,175 +1,5 @@
# Changelog
## [2.8.0] - 2026-04-16
### Added
- Back up your secret key right from Profile settings -- reveal, copy, and save it to iCloud Keychain, Android Credential Manager, or a local file
- Blobbi mission progress now persists across page refreshes, so your hatching and evolution journey picks up right where you left off
### Changed
- AI chat has been overhauled with a cleaner layout, the Dork mascot across empty states, and a clear path to grab Shakespeare credits when you run out
- Friendly error banners now explain when you've hit the rate limit or run out of AI credits, instead of cryptic failures
### Fixed
- Avatar shape selection during signup now actually saves to your profile
- Blobbi interaction missions now tally correctly the moment you start incubating or evolving
- Blobbi task progress displays the right numbers immediately on page load instead of showing 0 until everything catches up
## [2.7.1] - 2026-04-16
### Added
- Tap the Home tab while already on Home to scroll to the top and refresh your feed
- Blobbi hatch and evolve missions now count your existing posts, themes, and color moments retroactively -- no need to start from scratch
- New Blobbis begin incubating and evolving immediately after adoption, so every care action counts toward your next milestone
### Changed
- Signup's save-key step is clearer: the button now reads "Save Key", shows a spinner while saving, and warns you before the key is revealed on screen
- On de-Googled Android devices without a password manager, your key now safely falls back to a file in the app's Documents folder
- Wallet connections and device keys are now stored in the iOS Keychain and Android KeyStore for stronger at-rest protection
- Android's automatic cloud backup now excludes your wallet credentials
### Fixed
- Scroll position is preserved when you navigate back from a post, profile, or any other page -- no more getting bounced to the top of your feed
- Custom saved feeds now cache content and support infinite scroll like the Home, Ditto, and Global feeds
- Various security hardening across themes, letters, profile banners, direct messages, and sandboxed apps to protect against malformed data
## [2.7.0] - 2026-04-14
### Added
- Customizable widget sidebar -- drag, drop, and rearrange widgets on your feed including Trending, Hot Posts, Bluesky, AI Chat, Blobbi, Music, Photos, Wikipedia, and more
- Blobbi rooms -- swipe between living spaces, clean up after your pet, and earn XP from daily care routines
- Native push notifications on iOS with author names, content previews, and smart grouping by category
- Haptic feedback throughout the app -- taps, buzzes, and pulses when you react, zap, repost, pull to refresh, play games, and interact with your Blobbi
- Hot Posts widget showing the most popular posts from your feed at a glance
### Changed
- Sidebar widgets are now clickable links that take you to their full pages
- Blobbi widget shows live stats with circular ring indicators and quick action buttons
### Fixed
- Zaps embedded in posts now render as proper inline cards instead of blank space
- Quote posts display media and Blobbi companions correctly
- Deep linking on Google Play works again
- Game controller buttons no longer trigger text selection on long-press on iOS
## [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
-49
View File
@@ -1,49 +0,0 @@
#!/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', 'DittoNotificationPlugin'];
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(', ')}`);
}
}
+9 -16
View File
@@ -1,7 +1,8 @@
// 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, SystemBars, SystemBarsStyle } from "@capacitor/core";
import { Capacitor } from "@capacitor/core";
import { StatusBar, Style } from "@capacitor/status-bar";
import { NostrLoginProvider } from "@nostrify/react/login";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InferSeoMetaPlugin } from "@unhead/addons";
@@ -23,7 +24,6 @@ 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";
@@ -149,13 +149,6 @@ const hardcodedConfig: AppConfig = {
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
savedFeeds: [],
imageQuality: 'compressed',
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
{ id: 'wikipedia' },
],
};
/**
@@ -188,13 +181,13 @@ export function App() {
useNsecPasteGuard();
useEffect(() => {
// 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.
// Initialize StatusBar for mobile apps
if (Capacitor.isNativePlatform()) {
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
// SystemBars may not be available on all platforms
StatusBar.setStyle({ style: Style.Dark }).catch(() => {
// StatusBar may not be available on all platforms
});
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {
// Ignore errors on unsupported platforms
});
}
}, []);
@@ -205,7 +198,7 @@ export function App() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrLoginProvider storageKey="nostr:login">
<NostrProvider>
<NostrSync />
<NativeNotifications />
-4
View File
@@ -77,7 +77,6 @@ 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")!;
@@ -152,9 +151,6 @@ 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,16 +1,19 @@
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
import { useMemo } from 'react';
import { Loader2, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Loader2, ShoppingBag, Minus, Plus, 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';
@@ -34,8 +37,8 @@ interface BlobbiActionInventoryModalProps {
action: InventoryAction;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
/** Called when user taps Use on an item. Always uses once. */
onUseItem: (itemId: string) => void;
/** Called when user confirms using item(s). Now accepts quantity. */
onUseItem: (itemId: string, quantity: number) => void;
onOpenShop: () => void;
isUsingItem: boolean;
usingItemId: string | null;
@@ -46,19 +49,24 @@ 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);
// Get all available items for this action from the catalog (not inventory).
// Items are abilities/tools — no ownership required.
// Filter inventory by action type, respecting egg-compatible effects
const availableItems = useMemo(() => {
return filterInventoryByAction([], action, { stage: companion.stage });
}, [action, companion.stage]);
if (!profile) return [];
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
}, [profile, action, companion.stage]);
// Check stage restrictions for this specific action
const canUse = canUseAction(companion, action);
@@ -66,9 +74,46 @@ export function BlobbiActionInventoryModal({
const isEmpty = availableItems.length === 0;
const handleUseItem = (item: ResolvedInventoryItem) => {
const handleSelectItem = (item: ResolvedInventoryItem) => {
if (isUsingItem) return;
onUseItem(item.itemId);
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));
}
};
return (
@@ -116,10 +161,14 @@ 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 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.
<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!
</p>
<Button onClick={handleOpenShop} className="gap-2">
<ShoppingBag className="size-4" />
Open Shop
</Button>
</div>
)}
@@ -132,7 +181,7 @@ export function BlobbiActionInventoryModal({
item={item}
companion={companion}
action={action}
onUse={() => handleUseItem(item)}
onUse={() => handleSelectItem(item)}
isUsing={isUsingItem && usingItemId === item.itemId}
disabled={isUsingItem}
/>
@@ -141,6 +190,24 @@ 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>
);
}
@@ -171,12 +238,15 @@ 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(
@@ -185,6 +255,7 @@ function BlobbiInventoryUseRow({
),
};
}
// Normal stats preview
return {
normalStatChanges: previewStatChanges(companion.stats, item.effect),
eggStatChanges: [] as EggStatPreview[],
@@ -209,12 +280,16 @@ 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
@@ -233,6 +308,7 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -274,6 +350,7 @@ 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
@@ -292,6 +369,7 @@ function BlobbiInventoryUseRow({
</span>
</span>
))}
{/* Egg stat changes (health for medicine) */}
{eggStatChanges.map(({ stat, delta }) => (
<span key={stat} className="text-xs">
<span
@@ -315,3 +393,222 @@ 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>
);
}
@@ -17,6 +17,7 @@ import {
Loader2,
XCircle,
AlertTriangle,
Coins,
X,
Eye,
Scroll,
@@ -24,7 +25,7 @@ import {
HelpCircle,
ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatCompactNumber, cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
@@ -45,12 +46,14 @@ import {
} from '@/components/ui/alert-dialog';
import { useState } from 'react';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { NostrEvent } from '@nostrify/nostrify';
import type { HatchTasksResult } from '../hooks/useHatchTasks';
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
import { TasksPanel } from './TasksPanel';
import { DailyMissionsPanel } from './DailyMissionsPanel';
import { useDailyMissions } from '../hooks/useDailyMissions';
import { useClaimMissionReward } from '../hooks/useClaimMissionReward';
import { useRerollMission } from '../hooks/useRerollMission';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -59,6 +62,8 @@ interface BlobbiMissionsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
companion: BlobbiCompanion;
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
hatchTasks: HatchTasksResult;
evolveTasks: EvolveTasksResult;
onOpenPostModal: () => void;
@@ -141,12 +146,16 @@ function MissionTypeLegend() {
// ─── Daily Missions Section ───────────────────────────────────────────────────
interface DailyMissionsSectionProps {
profile: BlobbonautProfile | null;
updateProfileEvent: (event: NostrEvent) => void;
availableStages?: ('egg' | 'baby' | 'adult')[];
disabled?: boolean;
defaultOpen?: boolean;
}
function DailyMissionsSection({
profile,
updateProfileEvent,
availableStages,
disabled,
defaultOpen = true,
@@ -155,17 +164,23 @@ function DailyMissionsSection({
const {
missions,
todayXp,
allComplete,
bonusUnlocked,
bonusXp,
todayClaimedReward,
totalPotentialReward,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
} = useDailyMissions({ availableStages });
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
profile,
updateProfileEvent,
);
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
const completedCount = missions.filter((m) => m.complete).length;
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
@@ -179,12 +194,13 @@ function DailyMissionsSection({
<div className="flex items-center gap-2">
{/* Summary pill — always visible */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
<span className="tabular-nums">
{completedCount} / {missions.length}
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
</span>
{allComplete && (
{claimableCount > 0 && (
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
{claimableCount}
</span>
)}
</div>
@@ -197,11 +213,13 @@ function DailyMissionsSection({
<div className="pt-3">
<DailyMissionsPanel
missions={missions}
onClaimReward={(id) => claimReward({ missionId: id })}
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
todayXp={todayXp}
disabled={disabled || isRerolling}
bonusUnlocked={bonusUnlocked}
bonusXp={bonusXp}
todayCoins={todayClaimedReward}
disabled={disabled || isClaiming || isRerolling}
bonusAvailable={bonusAvailable}
bonusClaimed={bonusClaimed}
bonusReward={bonusReward}
noMissionsAvailable={noMissionsAvailable}
rerollsRemaining={rerollsRemaining}
isRerolling={isRerolling}
@@ -424,6 +442,8 @@ export function BlobbiMissionsModal({
open,
onOpenChange,
companion,
profile,
updateProfileEvent,
hatchTasks,
evolveTasks,
onOpenPostModal,
@@ -507,6 +527,8 @@ export function BlobbiMissionsModal({
{/* 2. Daily Bounties */}
<DailyMissionsSection
profile={profile}
updateProfileEvent={updateProfileEvent}
availableStages={availableStages}
disabled={isProcessBusy}
/>
@@ -2,16 +2,16 @@
* DailyMissionsPanel — card-grid layout for daily bounties.
*
* Each mission is a compact card in a 2-col grid.
* Tapping a card expands it to show progress and reroll.
* Tapping a card expands it to show progress, claim button, and reroll.
* Only one card expanded at a time.
* Completion is implicit (derived from progress vs target).
*/
import { useState } from 'react';
import {
Check,
Sparkles,
Coins,
Gift,
Sparkles,
Egg,
Trophy,
RefreshCw,
@@ -24,13 +24,13 @@ import {
Music,
Pill,
CircleDot,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn, formatCompactNumber } from '@/lib/utils';
import type { DailyMissionAction } from '../lib/daily-missions';
import type { DailyMissionView } from '../hooks/useDailyMissions';
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
import {
ExpandableMissionCard,
MissionDescription,
@@ -40,12 +40,14 @@ import {
// ─── Types ────────────────────────────────────────────────────────────────────
interface DailyMissionsPanelProps {
missions: DailyMissionView[];
missions: DailyMission[];
onClaimReward: (missionId: string) => void;
onRerollMission?: (missionId: string) => void;
todayXp: number;
todayCoins: number;
disabled?: boolean;
bonusUnlocked?: boolean;
bonusXp?: number;
bonusAvailable?: boolean;
bonusClaimed?: boolean;
bonusReward?: number;
noMissionsAvailable?: boolean;
rerollsRemaining?: number;
isRerolling?: boolean;
@@ -80,34 +82,51 @@ function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
// ─── Bonus Card ───────────────────────────────────────────────────────────────
interface BonusCardProps {
isUnlocked: boolean;
xp: number;
isAvailable: boolean;
isClaimed: boolean;
reward: number;
onClaim: () => void;
disabled?: boolean;
isExpanded: boolean;
onToggle: (id: string) => void;
}
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
return (
<ExpandableMissionCard
id="bonus"
category="daily"
icon={<Trophy className="size-5" />}
title="Daily Champion"
completed={isUnlocked}
progress={isUnlocked ? 1 : 0}
completed={isClaimed}
progress={progress}
isExpanded={isExpanded}
onToggle={onToggle}
>
<MissionDescription>
{isUnlocked
? 'Bonus XP for completing all daily missions!'
{isAvailable || isClaimed
? 'Bonus reward for completing all daily missions!'
: 'Complete all missions to unlock this bonus'}
</MissionDescription>
<div className="flex items-center gap-1 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
+{formatCompactNumber(xp)} XP
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
+{formatCompactNumber(reward)}
</div>
{isAvailable && !isClaimed && (
<Button
size="sm"
onClick={onClaim}
disabled={disabled}
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
>
<Trophy className="size-3.5 mr-1.5" />
Claim Bonus {formatCompactNumber(reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
}
@@ -128,7 +147,7 @@ function NoMissionsState() {
);
}
function AllCompleteState({ todayXp }: { todayXp: number }) {
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
return (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Sparkles className="size-5 text-primary/60" />
@@ -136,8 +155,8 @@ function AllCompleteState({ todayXp }: { todayXp: number }) {
<p className="text-sm font-medium">All done for today</p>
<p className="text-xs text-muted-foreground mt-0.5">
Earned{' '}
<span className="font-medium text-violet-600 dark:text-violet-400">
{formatCompactNumber(todayXp)} XP
<span className="font-medium text-amber-600 dark:text-amber-400">
{formatCompactNumber(todayCoins)} coins
</span>{' '}
come back tomorrow!
</p>
@@ -168,11 +187,13 @@ function RerollCounter({ remaining }: { remaining: number }) {
export function DailyMissionsPanel({
missions,
onClaimReward,
onRerollMission,
todayXp,
todayCoins,
disabled,
bonusUnlocked = false,
bonusXp = 50,
bonusAvailable = false,
bonusClaimed = false,
bonusReward = 50,
noMissionsAvailable = false,
rerollsRemaining = 0,
isRerolling = false,
@@ -181,8 +202,10 @@ export function DailyMissionsPanel({
if (noMissionsAvailable) return <NoMissionsState />;
const allComplete = missions.every((m) => m.complete);
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
const allRegularClaimed = missions.every((m) => m.claimed);
const allDone = allRegularClaimed && bonusClaimed;
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
@@ -197,8 +220,9 @@ export function DailyMissionsPanel({
{/* Regular mission cards */}
{missions.map((mission) => {
const progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
const showReroll = onRerollMission && !mission.complete && canReroll;
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
const canClaim = mission.completed && !mission.claimed;
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
return (
<ExpandableMissionCard
@@ -207,8 +231,8 @@ export function DailyMissionsPanel({
category="daily"
icon={<DailyMissionIcon action={mission.action} />}
title={mission.title}
completed={mission.complete}
progress={Math.min(progressFrac, 1)}
completed={mission.claimed}
progress={Math.min(progress, 1)}
isExpanded={expandedId === mission.id}
onToggle={handleToggle}
>
@@ -216,19 +240,19 @@ export function DailyMissionsPanel({
<MissionDescription>{mission.description}</MissionDescription>
{/* Progress */}
{!mission.complete && (
{!mission.claimed && (
<MissionProgress
current={mission.progress}
required={mission.target}
completed={mission.complete}
current={mission.currentCount}
required={mission.requiredCount}
completed={mission.completed}
/>
)}
{/* XP + reroll row */}
{/* Reward + reroll row */}
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<Zap className="size-3" />
{formatCompactNumber(mission.xp)} XP
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<Coins className="size-3" />
{formatCompactNumber(mission.reward)}
</span>
{showReroll && (
@@ -253,7 +277,7 @@ export function DailyMissionsPanel({
</TooltipProvider>
)}
{mission.complete && (
{mission.claimed && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
<Check className="size-2.5" />
Done
@@ -261,12 +285,20 @@ export function DailyMissionsPanel({
)}
</div>
{/* Complete indicator */}
{mission.complete && (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<Gift className="size-3.5" />
+{formatCompactNumber(mission.xp)} XP earned
</div>
{/* Claim button */}
{canClaim && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onClaimReward(mission.id);
}}
disabled={disabled}
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
>
<Gift className="size-3.5 mr-1.5" />
Claim {formatCompactNumber(mission.reward)} Coins
</Button>
)}
</ExpandableMissionCard>
);
@@ -274,8 +306,11 @@ export function DailyMissionsPanel({
{/* Bonus card */}
<BonusCard
isUnlocked={bonusUnlocked}
xp={bonusXp}
isAvailable={bonusAvailable}
isClaimed={bonusClaimed}
reward={bonusReward}
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
disabled={disabled}
isExpanded={expandedId === 'bonus'}
onToggle={handleToggle}
/>
@@ -98,6 +98,13 @@ export function InlineSingCard({
cleanup: cleanupPlayback,
} = useAudioPlayback();
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, []);
// Cleanup all resources
const cleanupAll = useCallback(() => {
// Stop timer
@@ -131,13 +138,6 @@ export function InlineSingCard({
}
}, [audioUrl, cleanupPlayback]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanupAll();
};
}, [cleanupAll]);
// Reset recording
const resetRecording = useCallback(() => {
cleanupAll();
+16 -16
View File
@@ -82,6 +82,22 @@ 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) {
@@ -126,22 +142,6 @@ 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 = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
const tasks = activeResult?.tasks ?? [];
const isLoading = activeResult?.isLoading ?? false;
const allCompleted = activeResult?.allCompleted ?? false;
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
@@ -17,7 +17,6 @@
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -25,12 +24,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
@@ -40,6 +34,8 @@ export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
export interface CareActivityResult {
@@ -63,8 +59,8 @@ export interface CareActivityResult {
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiCareActivityParams) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -82,24 +78,12 @@ 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 using fresh data
// Calculate what the streak update should be
const result = calculateStreakUpdate(
freshCompanion.careStreak,
freshCompanion.careStreakLastDay,
companion.careStreak,
companion.careStreakLastDay,
now
);
@@ -112,29 +96,29 @@ export function useBlobbiCareActivity({
};
}
// Get the tag updates using fresh data
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
// Get the tag updates
const streakUpdates = getStreakTagUpdates(companion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: freshCompanion.careStreak ?? 0,
newStreak: companion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags from fresh data
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
// Build updated tags
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: freshCompanion.event.content,
content: companion.event.content,
tags: updatedTags,
});
// Update local cache (optimistic — no invalidation needed)
// Update local cache
updateCompanionEvent(event);
// Update session tracker
@@ -144,9 +128,9 @@ export function useBlobbiCareActivity({
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: freshCompanion.careStreak,
previousStreak: companion.careStreak,
newStreak: result.newStreak,
lastDay: freshCompanion.careStreakLastDay,
lastDay: companion.careStreakLastDay,
newDay: result.newLastDay,
});
}
@@ -157,6 +141,11 @@ export function useBlobbiCareActivity({
action: result.action,
};
},
onSuccess: (result) => {
if (result.wasUpdated) {
invalidateCompanion();
}
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
@@ -16,12 +16,15 @@ import {
clampStat,
applyStat,
DIRECT_ACTION_METADATA,
incrementInteractionTaskTags,
type DirectAction,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
// Import NostrEvent type
import type { NostrEvent } from '@nostrify/nostrify';
@@ -66,11 +69,15 @@ 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 require selecting an item.
* Direct actions (play_music, sing) don't consume inventory items.
* They directly affect happiness stat.
*
* This hook:
@@ -85,6 +92,8 @@ export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -146,11 +155,13 @@ export function useBlobbiDirectAction({
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter in evolution missions
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
const updatedTags = canonical.allTags;
if (companionState === 'incubating' || companionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user.pubkey);
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
@@ -178,6 +189,12 @@ export function useBlobbiDirectAction({
updateCompanionEvent(blobbiEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
action,
happinessChange: happinessDelta,
+234 -37
View File
@@ -27,11 +27,6 @@ import {
updateBlobbiTags,
} from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { createHatchMissions, createEvolveMissions } from '../lib/evolution-missions';
import {
ensureSessionStore,
writeMissionsToStorage,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -71,6 +66,10 @@ 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;
}
/**
@@ -113,6 +112,8 @@ export function useStartIncubation({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -268,14 +269,12 @@ export function useStartIncubation({
});
updateCompanionEvent(event);
// ─── Populate evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: createHatchMissions() },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -330,6 +329,10 @@ 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;
}
/**
@@ -360,6 +363,8 @@ export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -430,19 +435,30 @@ export function useStopIncubation({
});
updateCompanionEvent(event);
// ─── Clear evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: [] },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
};
},
onSuccess: ({ name }) => {
toast({
title: 'Incubation stopped',
description: `${name} is no longer incubating. Task progress has been reset.`,
});
},
onError: (error: Error) => {
toast({
title: 'Failed to stop incubation',
description: error.message,
variant: 'destructive',
});
},
});
}
@@ -464,6 +480,10 @@ 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;
}
/**
@@ -491,6 +511,8 @@ export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -563,14 +585,12 @@ export function useStartEvolution({
});
updateCompanionEvent(event);
// ─── Populate evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: createEvolveMissions() },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -611,6 +631,10 @@ 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;
}
/**
@@ -641,6 +665,8 @@ export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -710,14 +736,12 @@ export function useStopEvolution({
});
updateCompanionEvent(event);
// ─── Clear evolution missions in session store ───
const currentMissions = ensureSessionStore(user.pubkey);
writeMissionsToStorage(
{ ...currentMissions, evolution: [] },
user.pubkey,
);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -739,4 +763,177 @@ export function useStopEvolution({
});
}
// ─── Sync Task Completions Hook ───────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_TASK_SYNC = import.meta.env.DEV;
/**
* Parameters for syncing task completions (works for both hatch and evolve).
*/
export interface UseSyncTaskCompletionsParams {
companion: BlobbiCompanion | null;
/** Called to ensure companion is canonical */
ensureCanonicalBeforeAction: () => Promise<{
companion: BlobbiCompanion;
content: string;
allTags: string[][];
wasMigrated: boolean;
profileAllTags: string[][];
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
/**
* Task completions to sync (from useHatchTasks or useEvolveTasks).
*/
export interface TaskCompletionToSync {
taskId: string;
completed: boolean;
}
/**
* Result of sync operation.
*/
export interface SyncTaskCompletionsResult {
/** Task IDs that were synced (empty if nothing needed) */
synced: string[];
/** Whether sync was skipped (no diff) */
skipped: boolean;
/** Reason for skip (for debugging) */
skipReason?: string;
}
/**
* Hook to sync persistent task completions to kind 31124 tags.
* Works for both hatch (incubating) and evolve (evolving) processes.
*
* CRITICAL: This is a cache-only sync. It must be:
* 1. Fully idempotent - calling multiple times with same data = no-op
* 2. Diff-based - only publish when tags would actually change
* 3. Safe - no last_interaction update (this is cache sync, not user action)
* 4. Only sync PERSISTENT tasks - dynamic tasks must NEVER be synced
*
* Source of truth = computed task state from Nostr events.
* Tags = cache layer for faster access.
*/
export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (tasksToSync: TaskCompletionToSync[]): Promise<SyncTaskCompletionsResult> => {
// ─── Early Guards ───
if (!user?.pubkey) {
return { synced: [], skipped: true, skipReason: 'no_user' };
}
if (!companion) {
return { synced: [], skipped: true, skipReason: 'no_companion' };
}
// Must be in an active task process (incubating or evolving)
if (companion.state !== 'incubating' && companion.state !== 'evolving') {
return { synced: [], skipped: true, skipReason: 'not_in_task_process' };
}
// ─── Compute Diff ───
// Get cached completions from companion.tasksCompleted (parsed from tags)
const cachedCompletions = new Set(companion.tasksCompleted);
// Get computed completions from tasks (works for both hatch and evolve)
const computedCompletions = tasksToSync
.filter(t => t.completed)
.map(t => t.taskId);
// Find tasks that are computed as complete but NOT in cache
const missingFromCache = computedCompletions.filter(id => !cachedCompletions.has(id));
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Diff check:', {
cachedCompletions: Array.from(cachedCompletions),
computedCompletions,
missingFromCache,
});
}
// If no diff, skip entirely
if (missingFromCache.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: no diff between computed and cached');
}
return { synced: [], skipped: true, skipReason: 'no_diff' };
}
// ─── Ensure Canonical ───
const canonical = await ensureCanonicalBeforeAction();
if (!canonical) {
return { synced: [], skipped: true, skipReason: 'canonical_failed' };
}
// ─── Build Updated Tags ───
// Re-check against canonical.allTags (may have updated since companion was parsed)
const existingCompletionTags = new Set(
canonical.allTags
.filter(tag => tag[0] === 'task_completed')
.map(tag => tag[1])
);
// Filter to only truly missing tags
const tagsToAdd = missingFromCache.filter(id => !existingCompletionTags.has(id));
if (tagsToAdd.length === 0) {
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Skipped: all tags already exist in canonical');
}
return { synced: [], skipped: true, skipReason: 'tags_already_exist' };
}
// Add only the missing task_completed tags
// CRITICAL: Do NOT update last_interaction - this is cache sync, not user action
const updatedTags = [
...canonical.allTags,
...tagsToAdd.map(id => ['task_completed', id]),
];
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Publishing:', {
tagsToAdd,
totalTags: updatedTags.length,
});
}
// ─── Publish ───
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: canonical.content,
tags: updatedTags,
});
updateCompanionEvent(event);
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
}
return { synced: tagsToAdd, skipped: false };
},
});
}
@@ -69,6 +69,10 @@ 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;
}
/**
@@ -109,6 +113,8 @@ export function useBlobbiHatch({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -200,14 +206,7 @@ export function useBlobbiHatch({
console.log('[Hatch] Tag repairs applied:', repairResult.repairs);
}
// ─── Auto-start evolution for newly hatched babies ───
// Applied AFTER tag validation because cleanupTaskTags repairs
// task-process states to 'active'. We intentionally set 'evolving'
// here so the baby starts its evolution journey immediately.
const newTags = updateBlobbiTags(repairResult.tags, {
state: 'evolving',
state_started_at: nowStr,
});
const newTags = repairResult.tags;
// ─── Generate New Content for Baby Stage ───
// CRITICAL: Content must reflect the new stage
@@ -221,6 +220,12 @@ export function useBlobbiHatch({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'egg',
@@ -263,6 +268,8 @@ export function useBlobbiEvolve({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -369,6 +376,12 @@ export function useBlobbiEvolve({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'baby',
@@ -6,43 +6,54 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } 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,
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
incrementInteractionTaskTags,
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '../lib/daily-mission-tracker';
import { trackMultipleDailyMissionActions } from '../lib/daily-mission-tracker';
import type { DailyMissionAction } from '../lib/daily-missions';
import { getStreakTagUpdates } from '../lib/blobbi-streak';
import { calculateInventoryActionXP, applyXPGain, formatXPGain } from '../lib/blobbi-xp';
import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
/**
* Request payload for using an item on a Blobbi companion
* Request payload for using an inventory item
*/
export interface UseItemRequest {
itemId: string;
action: InventoryAction;
/** Number of items to use (defaults to 1) */
quantity?: number;
}
/**
* Result of using an item on a Blobbi companion
* Result of using an inventory item
*/
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;
@@ -60,44 +71,50 @@ export interface UseBlobbiUseInventoryItemParams {
content: string;
allTags: string[][];
wasMigrated: boolean;
/** Latest profile tags after migration */
/** Latest profile tags after migration (use instead of profile.allTags) */
profileAllTags: string[][];
/** Latest profile storage after migration */
/** Latest profile storage after migration (use instead of profile.storage) */
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 item on a Blobbi companion.
*
* Items are reusable abilities sourced from the shop catalog no
* inventory ownership or quantity is required.
* Hook to use an inventory item on a Blobbi companion.
*
* This hook:
* 1. Validates the companion and item compatibility
* 2. Ensures canonical format before action
* 3. Applies accumulated decay, then item effects to Blobbi stats
* 4. Updates Blobbi state (kind 31124)
* 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
*/
export function useBlobbiUseInventoryItem({
companion,
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent: _updateProfileEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in to use items');
@@ -111,6 +128,11 @@ 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);
@@ -185,25 +207,78 @@ export function useBlobbiUseInventoryItem({
}
}
// ─── Apply Item Effects (single use) ───
// ─── Apply Item Effects ───
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
// won't give more than 100 health total.
//
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
// - clean: count when hygiene or happiness INCREASES
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
//
// Use canonical companion stage for egg checks
const isEggCompanion = canonical.companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
let effectiveItemCount = 0; // Number of items that produced intended effects
if (isEggCompanion && action === 'medicine') {
// Egg medicine handling:
// 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;
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
// Apply health effect N times in sequence with clamping at each step
// Only count items that actually INCREASED health (positive effect only)
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
const prevHealth = currentHealth;
currentHealth = applyStat(currentHealth, healthDelta);
// Only count as effective if health increased (not just changed)
if (healthDelta > 0 && currentHealth > prevHealth) {
effectiveItemCount++;
}
}
statsUpdate.health = currentHealth.toString();
// 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') {
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
// 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++;
}
}
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -214,12 +289,58 @@ 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 once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
// 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++;
}
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -240,18 +361,23 @@ export function useBlobbiUseInventoryItem({
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter in evolution missions
// If incubating or evolving, increment the interaction counter for tasks
const companionState = canonical.companion.state;
const updatedTags = canonical.allTags;
if (companionState === 'incubating' || companionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user?.pubkey);
let updatedTags = canonical.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(canonical.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
// ─── Apply XP Gain ───
const xpGained = calculateInventoryActionXP(action, 1);
// ─── Apply XP Gain (Based on effective item count) ───
// Only grant XP for items that actually changed stats.
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
// This prevents XP farming by mass-using items after stats are already maxed.
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
const currentXP = canonical.companion.experience ?? 0;
const newXP = applyXPGain(currentXP, xpGained);
@@ -271,25 +397,48 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// Items are free to use — no storage decrement needed.
// No query invalidation needed — the optimistic update above keeps the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
// ─── Update Profile Storage (kind 11125) ───
// Only decrement storage if the item actually exists in inventory.
// Items are free to use regardless of inventory state.
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
if (hasItemInStorage) {
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
}
// ─── Invalidate Queries ───
invalidateCompanion();
invalidateProfile();
return {
itemName: shopItem.name,
action,
quantity,
effectiveItemCount, // How many items actually changed stats
statsChanged,
xpGained,
newXP,
};
},
onSuccess: ({ itemName, action, xpGained }) => {
onSuccess: ({ itemName, action, quantity, xpGained }) => {
const actionMeta = ACTION_METADATA[action];
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
const xpText = formatXPGain(xpGained);
toast({
title: `${actionMeta.label} successful!`,
description: `Used ${itemName} on your Blobbi. ${xpText}`,
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
});
// Track daily mission progress
+188 -67
View File
@@ -1,121 +1,242 @@
/**
* useAwardDailyXp - Award XP for completed daily missions
*
* Completion is implicit (derived from progress vs target).
* This hook calculates the total XP earned today and persists
* the updated XP total to kind 11125 tags.
*
* Uses fetchFreshEvent to avoid stale-read overwrites when
* multiple mutations race (e.g. item use XP + daily XP).
* useClaimMissionReward - Hook for claiming daily mission rewards
*
* Handles:
* - Persisting coin rewards to kind 11125 Blobbonaut profile
* - Updating localStorage mission state
* - Idempotent claiming (prevents double-credit)
* - Optimistic cache updates
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbonautTags,
parseBlobbonautEvent,
} from '@/blobbi/core/lib/blobbi';
import { buildXpTagUpdates } from '@/blobbi/core/lib/progression';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { totalDailyXp } from '../lib/daily-missions';
import {
type DailyMissionsState,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AwardDailyXpRequest {
/** Current missions state to calculate XP from */
missions: MissionsContent;
export interface ClaimMissionRequest {
missionId: string;
}
export interface AwardDailyXpResult {
xpAwarded: number;
newTotalXp: number;
/** Special ID for claiming the bonus mission */
export const BONUS_MISSION_ID = 'bonus_daily_complete';
export interface ClaimMissionResult {
missionId: string;
coinsEarned: number;
newTotalCoins: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useClaimMissionReward] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to award XP for completed daily missions.
*
* @param updateProfileEvent - Callback to update profile in query cache
* Hook to claim daily mission rewards.
*
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
* ensuring rewards are stored on-chain rather than just in localStorage.
*
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
* @param updateProfileEvent - Callback to update the profile in the query cache
*/
export function useAwardDailyXp(
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
export function useClaimMissionReward(
currentProfile: BlobbonautProfile | null,
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ missions }: AwardDailyXpRequest): Promise<AwardDailyXpResult> => {
if (!user?.pubkey) throw new Error('Must be logged in');
mutationFn: async ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
if (!user?.pubkey) {
throw new Error('You must be logged in to claim rewards');
}
const xpToAward = totalDailyXp(missions);
if (xpToAward <= 0) return { xpAwarded: 0, newTotalXp: 0 };
if (!currentProfile) {
throw new Error('Profile not found');
}
// Fetch fresh profile from relays to avoid stale-read overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [user.pubkey],
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
}
// Handle bonus mission claim
if (missionId === BONUS_MISSION_ID) {
// Check if bonus is available
if (!isBonusMissionAvailable(missionsState!)) {
throw new Error('Bonus mission not available yet');
}
// Check if already claimed
if (isBonusMissionClaimed(missionsState!)) {
throw new Error('Bonus reward already claimed');
}
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
// Publish updated profile event
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: updatedTags,
});
// Update the query cache
updateProfileEvent(event);
// Update localStorage to mark bonus as claimed
const updatedState: DailyMissionsState = {
...missionsState!,
bonusClaimed: true,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true, isBonus: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
}
// Handle regular mission claim
const mission = missionsState!.missions.find(m => m.id === missionId);
if (!mission) {
throw new Error('Mission not found');
}
// Check if already claimed (idempotency check)
if (mission.claimed) {
throw new Error('Reward already claimed');
}
// Check if mission is completed
if (!mission.completed) {
throw new Error('Mission not completed yet');
}
const coinsToAdd = mission.reward;
const newTotalCoins = currentProfile.coins + coinsToAdd;
// Build updated tags with new coin balance
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
coins: newTotalCoins.toString(),
});
const freshProfile = prev ? parseBlobbonautEvent(prev) : undefined;
const currentXp = freshProfile?.xp ?? 0;
const newTotalXp = currentXp + xpToAward;
// Update XP and level tags on the fresh event's tags
const updatedTags = updateBlobbonautTags(
prev?.tags ?? [],
buildXpTagUpdates(newTotalXp),
);
// Persist missions state to content field
const content = serializeProfileContent(
prev?.content ?? '',
{ missions },
);
// Publish updated profile event to kind 11125
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content,
content: '',
tags: updatedTags,
prev: prev ?? undefined,
});
// Update the query cache optimistically
updateProfileEvent(event);
return { xpAwarded: xpToAward, newTotalXp };
// Now update localStorage to mark mission as claimed
const updatedMissions = missionsState!.missions.map(m =>
m.id === missionId ? { ...m, claimed: true } : m
);
const updatedState: DailyMissionsState = {
...missionsState!,
missions: updatedMissions,
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
};
writeMissionsState(updatedState);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, claimed: true }
}));
return {
missionId,
coinsEarned: coinsToAdd,
newTotalCoins,
};
},
onSuccess: ({ xpAwarded }) => {
onSuccess: ({ coinsEarned }) => {
// Invalidate profile query to ensure fresh data
if (user?.pubkey) {
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
}
if (xpAwarded > 0) {
toast({
title: 'XP Earned!',
description: `You earned ${xpAwarded} XP from daily missions.`,
});
}
// Show success toast
toast({
title: 'Reward Claimed!',
description: `You earned ${coinsEarned} coins.`,
});
},
onError: (error: Error) => {
// Don't show error for already claimed (user might have double-clicked)
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
return;
}
toast({
title: 'Failed to Award XP',
title: 'Failed to Claim Reward',
description: error.message,
variant: 'destructive',
});
},
});
}
// Legacy export name for backward compatibility during migration
export const useClaimMissionReward = useAwardDailyXp;
export type ClaimMissionRequest = AwardDailyXpRequest;
export type ClaimMissionResult = AwardDailyXpResult;
+153 -179
View File
@@ -1,227 +1,201 @@
/**
* useDailyMissions - Hook for reading daily mission state
*
* Provides reactive access to the current day's missions.
* Progress tracking is done via the tracker module (non-React).
* Completion is implicit (derived from count/events vs target).
* XP is awarded automatically when missions complete.
*
* State lives in a pubkey-scoped in-memory Map. On mount or account
* switch, hydrates from kind 11125 content JSON if the session store
* is empty. Completed missions are persisted by `useAwardDailyXp`;
* intermediate progress resets on page refresh.
* useDailyMissions - Hook for managing Blobbi daily missions
*
* Provides:
* - Daily mission state management with localStorage persistence
* - Automatic daily reset
* - Progress tracking functions
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
* - Stage-based filtering (only shows missions user can complete)
* - Bonus mission tracking
*
* Note: Reward claiming should be done via useClaimMissionReward hook,
* which persists coins to the kind 11125 Blobbonaut profile.
*/
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { useMemo, useEffect, useState, useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
import { parseProfileContent } from '@/blobbi/core/lib/missions';
import {
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsContent,
areAllDailyComplete,
totalDailyXp,
getDefinition,
createDailyMissionsState,
areAllMissionsCompleted,
areAllMissionsClaimed,
getTotalPotentialReward,
getTodayClaimedReward,
isBonusMissionAvailable,
isBonusMissionClaimed,
BONUS_MISSION_DEFINITION,
getRerollsRemaining,
MAX_DAILY_REROLLS,
DAILY_BONUS_XP,
} from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
hydrateFromPersisted,
} from '../lib/daily-mission-tracker';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DailyMissionView {
/** Mission ID (matches pool definition) */
id: string;
/** Display title */
title: string;
/** Description */
description: string;
/** Action type */
action: DailyMissionAction;
/** Required count */
target: number;
/** Current progress */
progress: number;
/** Whether mission is complete */
complete: boolean;
/** XP reward */
xp: number;
}
export interface UseDailyMissionsOptions {
/** Available Blobbi stages the user has (filters eligible missions) */
availableStages?: BlobbiStage[];
/**
* Raw content string from the kind 11125 profile event.
* Pass `profile.content` here. The hook parses it to extract
* persisted missions and hydrates the session store on first load.
*/
profileContent?: string;
}
export interface UseDailyMissionsResult {
/** Today's daily missions with computed progress */
missions: DailyMissionView[];
/** The raw missions content (for persistence/mutation hooks) */
raw: MissionsContent | undefined;
/** Whether all daily missions are complete */
allComplete: boolean;
/** Total XP earned today (completed missions + bonus) */
todayXp: number;
/** Whether the daily bonus is unlocked (all missions complete) */
bonusUnlocked: boolean;
/** Bonus XP amount */
bonusXp: number;
/** Whether user has no eligible missions */
/** Current daily missions state */
missions: DailyMission[];
/** Whether all missions are completed */
allCompleted: boolean;
/** Whether all missions are claimed */
allClaimed: boolean;
/** Total potential reward for today (including bonus if available) */
totalPotentialReward: number;
/** Total claimed reward for today */
todayClaimedReward: number;
/** Lifetime total coins earned from daily missions */
lifetimeCoinsEarned: number;
/** Whether the bonus mission is available (all regular missions completed) */
bonusAvailable: boolean;
/** Whether the bonus mission has been claimed */
bonusClaimed: boolean;
/** Bonus mission reward amount */
bonusReward: number;
/** Whether user has no eligible missions (e.g., only eggs) */
noMissionsAvailable: boolean;
/** Rerolls remaining today */
/** Number of rerolls remaining for today */
rerollsRemaining: number;
/** Max rerolls per day */
/** Maximum rerolls allowed per day */
maxRerolls: number;
/** Force refresh missions (testing) */
/** Force refresh missions (for testing or manual reset) */
forceReset: () => void;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useDailyMissions] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
const { availableStages, profileContent } = options;
const { availableStages } = options;
const { user } = useCurrentUser();
const pubkey = user?.pubkey;
// Version counter to trigger re-reads from session store
// Read state directly from localStorage, with a version counter to trigger re-reads
const [version, setVersion] = useState(0);
// Track whether we've hydrated for this pubkey
const hydratedRef = useRef<string | null>(null);
// Hydrate session store from kind 11125 content on mount / account switch
useEffect(() => {
if (!pubkey || !profileContent) return;
if (hydratedRef.current === pubkey) return; // already hydrated this session
// Check if session store already has data for this pubkey
const existing = readMissionsFromStorage(pubkey);
if (existing) {
hydratedRef.current = pubkey;
return;
}
// Parse persisted missions from profile content
const parsed = parseProfileContent(profileContent);
if (parsed.missions && !needsDailyReset(parsed.missions)) {
// Daily missions are still current — hydrate the full object
hydrateFromPersisted(parsed.missions, pubkey);
} else if (parsed.missions?.evolution?.length) {
// Daily missions need a reset, but evolution missions survive across days.
// Seed the store with fresh dailies + persisted evolution so the raw memo
// picks them up instead of creating missions with evolution: [].
const fresh = createDailyMissionsContent(
getTodayDateString(),
parsed.missions.evolution,
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
}
hydratedRef.current = pubkey;
// Read from localStorage on every render when version changes
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
const state = useMemo(() => readMissionsState(), [version]);
// Wrapper to write state and update version
const setState = useCallback((newState: DailyMissionsState) => {
writeMissionsState(newState);
setVersion((v) => v + 1);
}, [pubkey, profileContent]); // eslint-disable-line react-hooks/exhaustive-deps
// Listen for tracker events
useEffect(() => {
const handler = () => setVersion((v) => v + 1);
window.addEventListener('daily-missions-updated', handler);
return () => window.removeEventListener('daily-missions-updated', handler);
}, []);
// Stable stages key for deps
// Listen for external updates from mutations (reroll, claim, progress tracking)
// This re-reads localStorage when other hooks modify it directly
useEffect(() => {
const handleExternalUpdate = () => {
// Bump version to trigger a re-read from localStorage
setVersion((v) => v + 1);
};
window.addEventListener('daily-missions-updated', handleExternalUpdate);
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
}, []);
// Stable key for availableStages to use in dependencies
const stagesKey = availableStages?.sort().join(',') ?? '';
// Read and ensure current state.
// CRITICAL: Don't create a fresh store entry until hydration is complete.
// Creating one prematurely would overwrite persisted evolution missions
// because `hydrateFromPersisted` no-ops when the store already has data.
const hydrated = hydratedRef.current === pubkey;
const raw = useMemo((): MissionsContent | undefined => {
const stored = readMissionsFromStorage(pubkey);
if (!needsDailyReset(stored)) return stored;
// If the store is empty and we haven't hydrated yet, wait for the
// hydration effect to seed persisted data before creating fresh missions.
if (!stored && !hydrated) return undefined;
// Reset for new day, preserve evolution missions
const fresh = createDailyMissionsContent(
getTodayDateString(),
stored?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
return fresh;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version, pubkey, stagesKey, hydrated]);
// Build view models
const missions: DailyMissionView[] = useMemo(() => {
if (!raw?.daily) return [];
return raw.daily.map((m) => {
const def = getDefinition(m.id);
return {
id: m.id,
title: def?.title ?? m.id,
description: def?.description ?? '',
action: def?.action ?? 'interact',
target: m.target,
progress: missionProgress(m),
complete: isMissionComplete(m),
xp: def?.xp ?? 0,
// Ensure we have valid state for today
const currentState = useMemo(() => {
// Check if we need to reset for a new day
if (needsDailyReset(state)) {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
// Persist the reset state (this will trigger version bump via setState)
writeMissionsState(newState);
return newState;
}
// Migration: ensure rerollsRemaining is set for old state
if (state && state.rerollsRemaining === undefined) {
const migratedState = {
...state,
rerollsRemaining: MAX_DAILY_REROLLS,
};
});
}, [raw]);
const allComplete = raw ? areAllDailyComplete(raw) : false;
const todayXp = raw ? totalDailyXp(raw) : 0;
const bonusUnlocked = allComplete;
const noMissionsAvailable = missions.length === 0;
const rerollsRemaining = raw?.rerolls ?? MAX_DAILY_REROLLS;
const forceReset = useCallback(() => {
const fresh = createDailyMissionsContent(
getTodayDateString(),
raw?.evolution ?? [],
pubkey,
availableStages,
);
writeMissionsToStorage(fresh, pubkey);
setVersion((v) => v + 1);
writeMissionsState(migratedState);
return migratedState;
}
return state!;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey, stagesKey, raw?.evolution]);
}, [state, pubkey, stagesKey]);
// Force reset missions (for testing)
const forceReset = () => {
const previousCoins = state?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
setState(newState);
};
// Computed values
const missions = currentState.missions;
const allCompleted = areAllMissionsCompleted(currentState);
const allClaimed = areAllMissionsClaimed(currentState);
const bonusAvailable = isBonusMissionAvailable(currentState);
const bonusClaimed = isBonusMissionClaimed(currentState);
const bonusReward = BONUS_MISSION_DEFINITION.reward;
const noMissionsAvailable = missions.length === 0;
const rerollsRemaining = getRerollsRemaining(currentState);
const maxRerolls = MAX_DAILY_REROLLS;
// Total potential includes bonus if regular missions exist
const basePotentialReward = getTotalPotentialReward(currentState);
const totalPotentialReward = missions.length > 0
? basePotentialReward + bonusReward
: 0;
// Today's claimed includes bonus if claimed
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
return {
missions,
raw,
allComplete,
todayXp,
bonusUnlocked,
bonusXp: DAILY_BONUS_XP,
allCompleted,
allClaimed,
totalPotentialReward,
todayClaimedReward,
lifetimeCoinsEarned,
bonusAvailable,
bonusClaimed,
bonusReward,
noMissionsAvailable,
rerollsRemaining,
maxRerolls: MAX_DAILY_REROLLS,
maxRerolls,
forceReset,
};
}
+260 -154
View File
@@ -1,38 +1,29 @@
// src/blobbi/actions/hooks/useEvolveTasks.ts
/**
* Hook to compute evolve task progress.
*
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
* - Dynamic task (maintain_stats): computed from current companion stats, NEVER stored
* Hook to compute evolve task progress from Nostr events and current stats.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
* - DYNAMIC TASKS: Based on current stats, NEVER stored in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
*/
import { useEffect, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrFilter } from '@nostrify/nostrify';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
import {
EVOLVE_MISSIONS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_STAT_THRESHOLD,
findEvolutionMission,
createEvolveMissions,
} from '../lib/evolution-missions';
import {
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
KIND_PROFILE_METADATA,
KIND_SHORT_TEXT_NOTE,
BLOBBI_POST_REQUIRED_HASHTAGS,
sanitizeToHashtag,
type HatchTask,
type TaskType,
} from './useHatchTasks';
@@ -42,19 +33,29 @@ import {
/** Kind for custom profile tabs event */
export const KIND_PROFILE_TABS = 16769;
// Re-export for backward compat
export {
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_STAT_THRESHOLD,
};
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
/** Required color moments for evolve task */
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
/** Required posts for evolve task (lighter than hatch - just 1 evolve-specific post) */
export const EVOLVE_REQUIRED_POSTS = 1;
/** Required interactions for evolve task */
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
/** Prefix text for Blobbi evolve post */
export const BLOBBI_EVOLVE_POST_PREFIX = 'Hello Nostr! Posting to evolve';
/** Stat threshold for evolve dynamic task (all stats >= 80) */
export const EVOLVE_STAT_THRESHOLD = 80;
// ─── Types ────────────────────────────────────────────────────────────────────
// Re-export task types for convenience
export type { HatchTask as EvolveTask, TaskType };
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Result of computing evolve tasks.
*/
@@ -72,159 +73,256 @@ export interface EvolveTasksResult {
refetch: () => void;
}
// ─── Helper Functions ─────────────────────────────────────────────────────────
/**
* Check if a post is a valid Blobbi evolve post.
* Must contain the evolve prefix and all required hashtags including the Blobbi name.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
*/
export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolean {
// Check content starts with evolve prefix
if (!event.content.startsWith(BLOBBI_EVOLVE_POST_PREFIX)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (!hasRequiredHashtags) {
return false;
}
// Blobbi name hashtag must also be present
const blobbiHashtag = sanitizeToHashtag(blobbiName);
return hashtags.includes(blobbiHashtag);
}
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute evolve task progress from evolution missions + Nostr event backfill.
*
* Hook to compute evolve task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create 3 Themes (kind 36767)
* 2. Create 3 Color Moments (kind 3367)
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
* 4. Interact 21 times (tracked via companion.tasks cache)
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
*
* DYNAMIC TASK (stat-based, NEVER cached):
* 6. Maintain All Stats >= 80
*
* @param companion - The Blobbi companion (must be in evolving state)
* @param missions - Current MissionsContent from the session store
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useEvolveTasks(
companion: BlobbiCompanion | null,
missions: MissionsContent | undefined,
interactionCount?: number
): EvolveTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isEvolving = companion?.state === 'evolving';
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
// ─── Ensure evolution missions exist in session store ───
// Safety net: if the companion is evolving but evolution[] is empty
// (e.g. persist didn't fire, hydration lost them), re-populate from
// the static definitions so tally tracking works immediately.
const ensuredRef = useRef(false);
useEffect(() => {
if (!isEvolving || !pubkey || ensuredRef.current) return;
if (evolution.length > 0) { ensuredRef.current = true; return; }
const store = ensureSessionStore(pubkey);
if (store.evolution.length === 0) {
writeMissionsToStorage({ ...store, evolution: createEvolveMissions() }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
}
ensuredRef.current = true;
}, [isEvolving, pubkey, evolution]);
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['evolve-tasks', pubkey],
queryKey: ['evolve-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey) return null;
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
{ kinds: [KIND_THEME_DEFINITION], authors: [pubkey], limit: EVOLVE_REQUIRED_THEMES },
{ kinds: [KIND_COLOR_MOMENT], authors: [pubkey], limit: EVOLVE_REQUIRED_COLOR_MOMENTS },
{ kinds: [KIND_PROFILE_TABS], authors: [pubkey], limit: 1 },
{ kinds: [KIND_PROFILE_METADATA], authors: [pubkey], limit: 1 },
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid evolve posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Only need 1 valid evolve post
},
// Custom profile tabs after start
{
kinds: [KIND_PROFILE_TABS],
authors: [pubkey],
since: stateStartedAt,
limit: 1, // Only need 1
},
// Profile metadata after start (for Blobbi shape check + profile edit mission)
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
const profileTabsEvents = events.filter(e =>
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
);
// Get latest profile after start
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents: events.filter(e => e.kind === KIND_THEME_DEFINITION),
colorMomentEvents: events.filter(e => e.kind === KIND_COLOR_MOMENT),
profileTabsEvents: events.filter(e => e.kind === KIND_PROFILE_TABS),
hasProfileMetadata: events.some(e => e.kind === KIND_PROFILE_METADATA),
themeEvents,
colorMomentEvents,
postEvents,
profileTabsEvents,
profileAfter,
};
},
enabled: !!pubkey && isEvolving,
staleTime: 30_000,
refetchInterval: 60_000,
enabled: !!pubkey && !!stateStartedAt && isEvolving,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute event counts directly from Nostr query results ───
// These are the authoritative counts for event-based tasks.
const queryCounts: Record<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
return {
create_themes: data.themeEvents.length,
color_moments: data.colorMomentEvents.length,
edit_profile: (data.profileTabsEvents.length >= 1 || data.hasProfileMetadata) ? 1 : 0,
};
}, [data]);
// ─── Backfill event IDs into evolution missions (for persistence only) ───
const lastBackfilledDataRef = useRef<typeof data>(null);
useEffect(() => {
if (!data || !pubkey || evolution.length === 0) return;
if (data === lastBackfilledDataRef.current) return;
lastBackfilledDataRef.current = data;
const current = readMissionsFromStorage(pubkey);
if (!current || current.evolution.length === 0) return;
const evo = current.evolution;
for (const event of data.themeEvents) {
const m = findEvolutionMission(evo, 'create_themes');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_themes', event.id, pubkey);
}
}
for (const event of data.colorMomentEvents) {
const m = findEvolutionMission(evo, 'color_moments');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('color_moments', event.id, pubkey);
}
}
const profileEditEvents = [
...data.profileTabsEvents,
...(data.hasProfileMetadata ? [{ id: 'profile-metadata' }] : []),
];
for (const event of profileEditEvents) {
const m = findEvolutionMission(evo, 'edit_profile');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('edit_profile', event.id, pubkey);
}
}
}, [data, pubkey, evolution]);
// ─── Build task view models ───
// For event-based tasks, use the MAX of the Nostr query count and the
// evolution mission progress. The query is authoritative but the mission
// store may have progress from a previous session that hasn't been
// re-queried yet.
const tasks: HatchTask[] = EVOLVE_MISSIONS.map((def) => {
const mission = findEvolutionMission(evolution, def.id);
const missionCount = mission ? missionProgress(mission) : 0;
const queryCount = queryCounts[def.id] ?? 0;
const current = Math.max(missionCount, queryCount);
const completed = current >= def.target;
return {
id: def.id,
name: def.title,
description: def.description,
current: Math.min(current, def.target),
required: def.target,
completed,
type: 'persistent' as TaskType,
action: def.action,
actionTarget: def.actionTarget,
actionLabel: def.actionLabel,
};
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create 3 Themes (PERSISTENT)
const themeCount = data?.themeEvents?.length ?? 0;
const themesCompleted = themeCount >= EVOLVE_REQUIRED_THEMES;
tasks.push({
id: 'create_themes',
name: 'Create Themes',
description: `Create ${EVOLVE_REQUIRED_THEMES} custom themes`,
current: Math.min(themeCount, EVOLVE_REQUIRED_THEMES),
required: EVOLVE_REQUIRED_THEMES,
completed: themesCompleted,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
// ─── Dynamic Task: Maintain All Stats >= 80 ───
// 2. Create 3 Color Moments (PERSISTENT)
const colorMomentCount = data?.colorMomentEvents?.length ?? 0;
const colorMomentsCompleted = colorMomentCount >= EVOLVE_REQUIRED_COLOR_MOMENTS;
tasks.push({
id: 'color_moments',
name: 'Color Moments',
description: `Share ${EVOLVE_REQUIRED_COLOR_MOMENTS} color moments on espy`,
current: Math.min(colorMomentCount, EVOLVE_REQUIRED_COLOR_MOMENTS),
required: EVOLVE_REQUIRED_COLOR_MOMENTS,
completed: colorMomentsCompleted,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create 1 Evolve Post (PERSISTENT) - lighter than hatch
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidEvolvePost(e, blobbiName)) ?? [];
const postCount = validPosts.length;
const postsCompleted = postCount >= EVOLVE_REQUIRED_POSTS;
tasks.push({
id: 'create_posts',
name: 'Share Evolution',
description: 'Post about your Blobbi evolving',
current: Math.min(postCount, EVOLVE_REQUIRED_POSTS),
required: EVOLVE_REQUIRED_POSTS,
completed: postsCompleted,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 4. Interact 21 times (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= EVOLVE_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${EVOLVE_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, EVOLVE_REQUIRED_INTERACTIONS),
required: EVOLVE_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
const hasMetadataEdit = !!data?.profileAfter;
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
tasks.push({
id: 'edit_profile',
name: 'Edit Your Profile',
description: 'Update your profile info or customize your profile tabs',
current: hasProfileEdit ? 1 : 0,
required: 1,
completed: hasProfileEdit,
type: 'persistent',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Profile',
});
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
// 7. Maintain All Stats >= 80
const stats = companion?.stats ?? {};
const hunger = stats.hunger ?? 0;
const happiness = stats.happiness ?? 0;
const health = stats.health ?? 0;
const hygiene = stats.hygiene ?? 0;
const energy = stats.energy ?? 0;
const statsOk =
const statsOk =
hunger >= EVOLVE_STAT_THRESHOLD &&
happiness >= EVOLVE_STAT_THRESHOLD &&
health >= EVOLVE_STAT_THRESHOLD &&
hygiene >= EVOLVE_STAT_THRESHOLD &&
energy >= EVOLVE_STAT_THRESHOLD;
// Calculate minimum stat for progress display
const minStat = Math.min(hunger, happiness, health, hygiene, energy);
tasks.push({
id: 'maintain_stats',
name: 'Peak Condition',
@@ -232,17 +330,18 @@ export function useEvolveTasks(
current: statsOk ? EVOLVE_STAT_THRESHOLD : minStat,
required: EVOLVE_STAT_THRESHOLD,
completed: statsOk,
type: 'dynamic',
type: 'dynamic', // CRITICAL: Never persist this task
// No action - just care for your Blobbi
});
// ─── Completion ───
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
@@ -254,4 +353,11 @@ export function useEvolveTasks(
};
}
/**
* Get the current interaction count for evolve from companion task cache.
*/
export function getEvolveInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
+230 -143
View File
@@ -1,33 +1,23 @@
// src/blobbi/actions/hooks/useHatchTasks.ts
/**
* Hook to compute hatch task progress.
*
* Progress is stored in `MissionsContent.evolution[]` on kind 11125.
* - Interactions: TallyMission tracked via `trackEvolutionMissionTally`
* - Event-based tasks: EventMission, backfilled from retroactive Nostr queries
*
* The Nostr queries discover event IDs that satisfy event-based tasks and
* feed them into the evolution tracker. The evolution array is the source of
* truth for completion state.
* Hook to compute hatch task progress from Nostr events.
*
* CRITICAL ARCHITECTURE:
* - PERSISTENT TASKS: Based on Nostr events, can be cached in tags
*
* Tags are only cache for persistent tasks. Source of truth = Nostr events.
* All persistent tasks are computed dynamically from events with created_at >= state_started_at.
*
* Note: Egg stats no longer decay, so there are no dynamic tasks for hatching.
*/
import { useEffect, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import { missionProgress, isEventMission } from '@/blobbi/core/lib/missions';
import { trackEvolutionMissionEvent, readMissionsFromStorage, ensureSessionStore, writeMissionsToStorage } from '../lib/daily-mission-tracker';
import {
HATCH_MISSIONS,
HATCH_REQUIRED_INTERACTIONS,
findEvolutionMission,
createHatchMissions,
} from '../lib/evolution-missions';
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -40,6 +30,9 @@ export const KIND_PROFILE_METADATA = 0;
/** Kind for short text notes */
export const KIND_SHORT_TEXT_NOTE = 1;
/** Required interactions to complete the hatch interactions task */
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
@@ -47,20 +40,36 @@ export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
// Legacy export for backwards compatibility
export { HATCH_REQUIRED_INTERACTIONS };
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
/**
* Sanitize a name into a valid hashtag format.
* Must match the implementation in BlobbiPostModal.tsx.
*/
export function sanitizeToHashtag(name: string): string {
return name
.toLowerCase()
// Remove emojis and special characters, keep letters, numbers, underscores
.replace(/[^\p{L}\p{N}_]/gu, '')
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
.replace(/^(\d)/, 'blobbi$1')
// Limit length
.slice(0, 30)
// Fallback if empty
|| 'myblobbi';
}
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Task type classification.
* - persistent: Based on Nostr events or tallies, stored in evolution[]
* - dynamic: Based on current stats, NEVER stored
* - persistent: Based on Nostr events, can be cached in tags
* - dynamic: Based on current stats, NEVER stored in tags
*/
export type TaskType = 'persistent' | 'dynamic';
/**
* Individual task view model for the UI.
* Individual task definition.
*/
export interface HatchTask {
id: string;
@@ -72,7 +81,7 @@ export interface HatchTask {
required: number;
/** Whether the task is complete */
completed: boolean;
/** Task type - persistent or dynamic */
/** Task type - persistent (event-based) or dynamic (stat-based) */
type: TaskType;
/** Action to perform (if applicable) */
action?: 'navigate' | 'open_modal' | 'external_link';
@@ -111,152 +120,221 @@ export function buildHatchPhrase(blobbiName: string): string {
}
/**
* Check if a post is a valid Blobbi-related post.
* Check if a post is a valid Blobbi hatch post.
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
* The user may add extra text before or after it.
*
* @param event - The Nostr event to validate
* @param blobbiName - The Blobbi's name
*/
export function isValidHatchPost(event: NostrEvent): boolean {
const hasBlobbiTag = event.tags.some(
tag => tag[0] === 't' && tag[1]?.toLowerCase() === 'blobbi',
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
const phrase = buildHatchPhrase(blobbiName);
// The phrase must appear somewhere in the content
if (!event.content.includes(phrase)) {
return false;
}
// Check for required hashtags in tags
const hashtags = event.tags
.filter(tag => tag[0] === 't')
.map(tag => tag[1]?.toLowerCase());
// All required hashtags must be present as t tags
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
hashtags.includes(required.toLowerCase())
);
if (hasBlobbiTag) return true;
return /#blobbi\b/i.test(event.content);
return hasRequiredHashtags;
}
// Legacy function name for backwards compatibility
export const isValidBlobbiPost = isValidHatchPost;
// ─── Main Hook ────────────────────────────────────────────────────────────────
/**
* Hook to compute hatch task progress from evolution missions + Nostr event backfill.
*
* Hook to compute hatch task progress from Nostr events and current stats.
*
* PERSISTENT TASKS (event-based, can be cached):
* 1. Create Theme (kind 36767) - 1 event after start
* 2. Color Moment (kind 3367) - 1 event after start
* 3. Create Post (kind 1) - 1 valid Blobbi hatch post after start
* 4. Interactions - 7 total (tracked via companion.tasks cache)
*
* Note: Egg stats no longer decay, so the "maintain stats" dynamic task
* has been removed. The baby/adult evolve equivalent is still in useEvolveTasks.
*
* @param companion - The Blobbi companion (must be incubating)
* @param missions - Current MissionsContent from the session store
* @param interactionCount - Current interaction count from companion tasks cache
*/
export function useHatchTasks(
companion: BlobbiCompanion | null,
missions: MissionsContent | undefined,
interactionCount?: number
): HatchTasksResult {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const pubkey = user?.pubkey;
const stateStartedAt = companion?.stateStartedAt;
const isIncubating = companion?.state === 'incubating';
const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]);
// ─── Ensure evolution missions exist in session store ───
// Safety net: if the companion is incubating but evolution[] is empty
// (e.g. persist didn't fire, hydration lost them), re-populate from
// the static definitions so tally tracking works immediately.
const ensuredRef = useRef(false);
useEffect(() => {
if (!isIncubating || !pubkey || ensuredRef.current) return;
if (evolution.length > 0) { ensuredRef.current = true; return; }
const store = ensureSessionStore(pubkey);
if (store.evolution.length === 0) {
writeMissionsToStorage({ ...store, evolution: createHatchMissions() }, pubkey);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { evolution: true } }));
}
ensuredRef.current = true;
}, [isIncubating, pubkey, evolution]);
// ─── Retroactive Nostr Queries (discover event IDs to backfill) ───
// Query for all relevant events
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['hatch-tasks', pubkey],
queryKey: ['hatch-tasks', pubkey, stateStartedAt],
queryFn: async () => {
if (!pubkey) return null;
if (!pubkey || !stateStartedAt) {
return null;
}
// Build filters for events we need
const filters: NostrFilter[] = [
{ kinds: [KIND_THEME_DEFINITION], authors: [pubkey], limit: 1 },
{ kinds: [KIND_COLOR_MOMENT], authors: [pubkey], limit: 1 },
{ kinds: [KIND_SHORT_TEXT_NOTE], authors: [pubkey], '#t': ['blobbi'], limit: 1 },
// Theme definitions after start
{
kinds: [KIND_THEME_DEFINITION],
authors: [pubkey],
since: stateStartedAt,
},
// Color moments after start
{
kinds: [KIND_COLOR_MOMENT],
authors: [pubkey],
since: stateStartedAt,
},
// Posts after start (will filter for valid Blobbi posts)
{
kinds: [KIND_SHORT_TEXT_NOTE],
authors: [pubkey],
since: stateStartedAt,
limit: 50, // Reasonable limit
},
// Profile metadata - need both before and after start
// Get latest before start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
until: stateStartedAt,
limit: 1,
},
// Get latest after start
{
kinds: [KIND_PROFILE_METADATA],
authors: [pubkey],
since: stateStartedAt,
limit: 1,
},
];
// Execute all queries
const events = await nostr.query(filters);
// Categorize events
const themeEvents = events.filter(e =>
e.kind === KIND_THEME_DEFINITION && e.created_at >= stateStartedAt
);
const colorMomentEvents = events.filter(e =>
e.kind === KIND_COLOR_MOMENT && e.created_at >= stateStartedAt
);
const postEvents = events.filter(e =>
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
);
// Separate profile events into before and after
const profileEvents = events.filter(e => e.kind === KIND_PROFILE_METADATA);
const profileBefore = profileEvents
.filter(e => e.created_at < stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
const profileAfter = profileEvents
.filter(e => e.created_at >= stateStartedAt)
.sort((a, b) => b.created_at - a.created_at)[0];
return {
themeEvents: events.filter(e => e.kind === KIND_THEME_DEFINITION),
colorMomentEvents: events.filter(e => e.kind === KIND_COLOR_MOMENT),
postEvents: events.filter(e => e.kind === KIND_SHORT_TEXT_NOTE),
themeEvents,
colorMomentEvents,
postEvents,
profileBefore,
profileAfter,
};
},
enabled: !!pubkey && isIncubating,
staleTime: 30_000,
refetchInterval: 60_000,
enabled: !!pubkey && !!stateStartedAt && isIncubating,
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute
});
// ─── Compute event counts directly from Nostr query results ───
// These are the authoritative counts for event-based tasks.
const queryCounts: Record<string, number> = useMemo(() => {
if (!data) return {} as Record<string, number>;
const validPosts = data.postEvents.filter(e => isValidHatchPost(e));
return {
create_theme: data.themeEvents.length,
color_moment: data.colorMomentEvents.length,
create_post: validPosts.length,
};
}, [data]);
// ─── Backfill event IDs into evolution missions (for persistence only) ───
const lastBackfilledDataRef = useRef<typeof data>(null);
useEffect(() => {
if (!data || !pubkey || evolution.length === 0) return;
if (data === lastBackfilledDataRef.current) return;
lastBackfilledDataRef.current = data;
const current = readMissionsFromStorage(pubkey);
if (!current || current.evolution.length === 0) return;
const evo = current.evolution;
for (const event of data.themeEvents) {
const m = findEvolutionMission(evo, 'create_theme');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_theme', event.id, pubkey);
}
}
for (const event of data.colorMomentEvents) {
const m = findEvolutionMission(evo, 'color_moment');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('color_moment', event.id, pubkey);
}
}
for (const event of data.postEvents) {
if (!isValidHatchPost(event)) continue;
const m = findEvolutionMission(evo, 'create_post');
if (m && isEventMission(m) && !m.events.includes(event.id)) {
trackEvolutionMissionEvent('create_post', event.id, pubkey);
}
}
}, [data, pubkey, evolution]);
// ─── Build task view models ───
// For event-based tasks, use the MAX of the Nostr query count and the
// evolution mission progress. The query is authoritative but the mission
// store may have progress from a previous session that hasn't been
// re-queried yet.
const tasks: HatchTask[] = HATCH_MISSIONS.map((def) => {
const mission = findEvolutionMission(evolution, def.id);
const missionCount = mission ? missionProgress(mission) : 0;
const queryCount = queryCounts[def.id] ?? 0;
const current = Math.max(missionCount, queryCount);
const completed = current >= def.target;
return {
id: def.id,
name: def.title,
description: def.description,
current: Math.min(current, def.target),
required: def.target,
completed,
type: 'persistent' as TaskType,
action: def.action,
actionTarget: def.actionTarget,
actionLabel: def.actionLabel,
};
// ─── Compute PERSISTENT Tasks ───
const tasks: HatchTask[] = [];
// 1. Create Theme (PERSISTENT)
const hasTheme = (data?.themeEvents?.length ?? 0) >= 1;
tasks.push({
id: 'create_theme',
name: 'Create Theme',
description: 'Create a custom theme for your profile',
current: hasTheme ? 1 : 0,
required: 1,
completed: hasTheme,
type: 'persistent',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
});
const persistentTasksComplete = tasks.every(t => t.completed);
const dynamicTaskComplete = true; // No dynamic tasks for hatching
// 2. Color Moment (PERSISTENT)
const hasColorMoment = (data?.colorMomentEvents?.length ?? 0) >= 1;
tasks.push({
id: 'color_moment',
name: 'Color Moment',
description: 'Share a color moment on espy',
current: hasColorMoment ? 1 : 0,
required: 1,
completed: hasColorMoment,
type: 'persistent',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Create Post (PERSISTENT)
const blobbiName = companion?.name ?? '';
const validPosts = data?.postEvents?.filter(e => isValidHatchPost(e, blobbiName)) ?? [];
const hasValidPost = validPosts.length >= 1;
tasks.push({
id: 'create_post',
name: 'Create Post',
description: 'Share a post about hatching your Blobbi',
current: hasValidPost ? 1 : 0,
required: 1,
completed: hasValidPost,
type: 'persistent',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
});
// 5. Interactions (PERSISTENT)
const interactions = interactionCount ?? 0;
const interactionsCompleted = interactions >= HATCH_REQUIRED_INTERACTIONS;
tasks.push({
id: 'interactions',
name: 'Interact with Blobbi',
description: `Care for your Blobbi ${HATCH_REQUIRED_INTERACTIONS} times`,
current: Math.min(interactions, HATCH_REQUIRED_INTERACTIONS),
required: HATCH_REQUIRED_INTERACTIONS,
completed: interactionsCompleted,
type: 'persistent',
// No action - just interact with Blobbi
});
// ─── Compute Completion States ───
const persistentTasks = tasks.filter(t => t.type === 'persistent');
const dynamicTasks = tasks.filter(t => t.type === 'dynamic');
const persistentTasksComplete = persistentTasks.every(t => t.completed);
const dynamicTaskComplete = dynamicTasks.every(t => t.completed);
const allCompleted = persistentTasksComplete && dynamicTaskComplete;
return {
tasks,
persistentTasksComplete,
@@ -268,6 +346,15 @@ export function useHatchTasks(
};
}
/**
* Get the current interaction count from companion task cache.
*/
export function getInteractionCount(companion: BlobbiCompanion | null): number {
if (!companion) return 0;
const interactionTask = companion.tasks.find(t => t.name === 'interactions');
return interactionTask?.value ?? 0;
}
/**
* Filter tasks to only persistent tasks (for tag sync).
* CRITICAL: Dynamic tasks must NEVER be synced to tags.
@@ -1,42 +0,0 @@
/**
* useItemCooldown React hook for per-item cooldown state.
*
* Subscribes to the shared item-cooldown singleton so components
* re-render when any item's cooldown starts or expires.
*
* Usage:
* ```tsx
* const { isOnCooldown } = useItemCooldown();
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
* ```
*/
import { useCallback, useSyncExternalStore } from 'react';
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
/** Monotonic version counter bumped by the subscription callback. */
let snapshotVersion = 0;
function subscribe(onStoreChange: () => void): () => void {
// subscribeCooldowns returns an unsubscribe function.
// The callback bumps the version AND notifies React.
return subscribeCooldowns(() => {
snapshotVersion++;
onStoreChange();
});
}
function getSnapshot(): number {
return snapshotVersion;
}
export function useItemCooldown() {
useSyncExternalStore(subscribe, getSnapshot);
const isOnCooldown = useCallback((itemId: string): boolean => {
return isItemOnCooldown(itemId);
}, []);
return { isOnCooldown };
}
@@ -1,107 +0,0 @@
/**
* usePersistEvolutionProgress - Debounced persistence for evolution mission progress.
*
* Evolution missions (hatch/evolve tasks) live in `MissionsContent.evolution[]`
* in the in-memory session store. This hook listens for changes and debounce-
* publishes the updated state to kind 11125 content JSON so progress survives
* page refreshes.
*
* Design:
* - Listens to 'daily-missions-updated' CustomEvent (same event the tracker fires)
* - Only acts on events with `detail.evolution === true`
* - Debounces by PERSIST_DELAY_MS to batch rapid interactions
* - Uses fetchFreshEvent to avoid stale-read overwrites
* - Skips publish if evolution[] is empty (no active task process)
*/
import { useEffect, useRef, useCallback } from 'react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import {
KIND_BLOBBONAUT_PROFILE,
} from '@/blobbi/core/lib/blobbi';
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
import { readMissionsFromStorage } from '../lib/daily-mission-tracker';
import type { NostrEvent } from '@nostrify/nostrify';
// ─── Constants ────────────────────────────────────────────────────────────────
/** Delay before persisting evolution progress (ms). */
const PERSIST_DELAY_MS = 5_000;
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* @param updateProfileEvent - Callback to update profile in query cache
*/
export function usePersistEvolutionProgress(
updateProfileEvent: (event: NostrEvent) => void,
): void {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishingRef = useRef(false);
const persist = useCallback(async () => {
const pubkey = user?.pubkey;
if (!pubkey || publishingRef.current) return;
const missions = readMissionsFromStorage(pubkey);
if (!missions || missions.evolution.length === 0) return;
publishingRef.current = true;
try {
const prev = await fetchFreshEvent(nostr, {
kinds: [KIND_BLOBBONAUT_PROFILE],
authors: [pubkey],
});
const content = serializeProfileContent(
prev?.content ?? '',
{ missions },
);
const event = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content,
tags: prev?.tags ?? [],
prev: prev ?? undefined,
});
updateProfileEvent(event);
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', pubkey] });
} finally {
publishingRef.current = false;
}
}, [user?.pubkey, nostr, publishEvent, updateProfileEvent, queryClient]);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.evolution) return;
// Clear any pending timer and restart the debounce
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
persist().catch((err) => {
console.warn('[PersistEvolution] Failed to persist:', err);
});
}, PERSIST_DELAY_MS);
};
window.addEventListener('daily-missions-updated', handler);
return () => {
window.removeEventListener('daily-missions-updated', handler);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [persist]);
}
+107 -31
View File
@@ -1,7 +1,11 @@
/**
* useRerollMission - Replace a daily mission with a new one from the pool
*
* Updates the in-memory session store.
* useRerollMission - Hook for rerolling daily missions
*
* Handles:
* - Replacing a mission with a new one from the pool
* - Tracking reroll usage (max 3 per day)
* - Respecting stage-based mission filtering
* - Persisting state to localStorage
*/
import { useMutation } from '@tanstack/react-query';
@@ -9,12 +13,17 @@ import { useMutation } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import type { BlobbiStage } from '../lib/daily-missions';
import { rerollMission, getDefinition } from '../lib/daily-missions';
import {
readMissionsFromStorage,
writeMissionsToStorage,
} from '../lib/daily-mission-tracker';
type DailyMissionsState,
type DailyMission,
type BlobbiStage,
getTodayDateString,
needsDailyReset,
createDailyMissionsState,
rerollMission,
canRerollMission,
getRerollsRemaining,
} from '../lib/daily-missions';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -25,51 +34,118 @@ export interface RerollMissionRequest {
export interface RerollMissionResult {
oldMissionId: string;
newMissionId: string;
newMission: DailyMission;
rerollsRemaining: number;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
function readMissionsState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const state = JSON.parse(stored) as DailyMissionsState;
// Migration: ensure rerollsRemaining is set for old state
if (state.rerollsRemaining === undefined) {
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
}
return state;
} catch {
return null;
}
}
function writeMissionsState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[useRerollMission] Failed to write state:', error);
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Hook to reroll a daily mission.
*
* Replaces the specified mission with a new one from the pool,
* respecting stage-based filtering and avoiding duplicates.
*/
export function useRerollMission() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
if (!user?.pubkey) throw new Error('Must be logged in');
if (!user?.pubkey) {
throw new Error('You must be logged in to reroll missions');
}
const current = readMissionsFromStorage(user.pubkey);
if (!current) throw new Error('No missions state');
// Read current missions state from localStorage
let missionsState = readMissionsState();
// Ensure we have valid state for today
if (needsDailyReset(missionsState)) {
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
}
const updated = rerollMission(current, missionId, availableStages);
if (!updated) throw new Error('Cannot reroll this mission');
// Check if reroll is allowed
if (!canRerollMission(missionsState!, missionId)) {
const rerollsLeft = getRerollsRemaining(missionsState!);
if (rerollsLeft <= 0) {
throw new Error('No rerolls remaining today');
}
const mission = missionsState!.missions.find(m => m.id === missionId);
if (mission?.completed || mission?.claimed) {
throw new Error('Cannot reroll completed or claimed missions');
}
throw new Error('Cannot reroll this mission');
}
writeMissionsToStorage(updated, user.pubkey);
// Perform the reroll
const result = rerollMission(missionsState!, missionId, availableStages);
if (!result) {
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
}
// Notify React
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: { missionId, rerolled: true },
// Persist the updated state
writeMissionsState(result.state);
// Dispatch event for React components to re-render
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
detail: {
missionId,
rerolled: true,
newMissionId: result.newMission.id,
}
}));
// Find the new mission ID at the same index
const oldIdx = current.daily.findIndex((m) => m.id === missionId);
const newMissionId = updated.daily[oldIdx]?.id ?? missionId;
return {
oldMissionId: missionId,
newMissionId,
rerollsRemaining: updated.rerolls,
newMission: result.newMission,
rerollsRemaining: getRerollsRemaining(result.state),
};
},
onSuccess: ({ newMissionId, rerollsRemaining }) => {
const def = getDefinition(newMissionId);
const rerollText = rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} reroll${rerollsRemaining === 1 ? '' : 's'} left`;
onSuccess: ({ newMission, rerollsRemaining }) => {
const rerollText = rerollsRemaining === 1
? '1 reroll left'
: rerollsRemaining === 0
? 'No rerolls left'
: `${rerollsRemaining} rerolls left`;
toast({
title: 'Mission Replaced',
description: `New mission: ${def?.title ?? newMissionId}. ${rerollText}.`,
description: `New mission: ${newMission.title}. ${rerollText}.`,
});
},
onError: (error: Error) => {
+16 -51
View File
@@ -30,6 +30,7 @@ export {
useStopIncubation,
useStartEvolution,
useStopEvolution,
useSyncTaskCompletions,
} from './hooks/useBlobbiIncubation';
export type {
StartIncubationMode,
@@ -42,6 +43,8 @@ export type {
StartEvolutionResult,
UseStopEvolutionParams,
StopEvolutionResult,
UseSyncTaskCompletionsParams,
TaskCompletionToSync,
} from './hooks/useBlobbiIncubation';
export { useActiveTaskProcess, filterPersistentTasks as filterPersistentTasksFromProcess, filterDynamicTasks } from './hooks/useActiveTaskProcess';
@@ -49,7 +52,9 @@ export type { TaskProcessType, TaskProcessConfig, ActiveTaskProcessResult } from
export {
useHatchTasks,
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
@@ -61,11 +66,15 @@ export type { HatchTask, HatchTasksResult, TaskType } from './hooks/useHatchTask
export {
useEvolveTasks,
getEvolveInteractionCount,
isValidEvolvePost,
KIND_PROFILE_TABS,
EVOLVE_REQUIRED_THEMES,
EVOLVE_REQUIRED_COLOR_MOMENTS,
EVOLVE_REQUIRED_POSTS,
EVOLVE_REQUIRED_INTERACTIONS,
EVOLVE_STAT_THRESHOLD,
BLOBBI_EVOLVE_POST_PREFIX,
} from './hooks/useEvolveTasks';
export type { EvolveTasksResult } from './hooks/useEvolveTasks';
@@ -110,6 +119,7 @@ export {
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
type IncrementInteractionResult,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
@@ -140,70 +150,25 @@ export {
hasHygieneEffectForEgg,
canUseItemForStage,
getActionForItem,
incrementInteractionTaskTags,
} from './lib/blobbi-action-utils';
// Daily Missions
export { useDailyMissions } from './hooks/useDailyMissions';
export type { DailyMissionView, UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useAwardDailyXp, useClaimMissionReward } from './hooks/useClaimMissionReward';
export { usePersistEvolutionProgress } from './hooks/usePersistEvolutionProgress';
export type { AwardDailyXpRequest, AwardDailyXpResult, ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export { useRerollMission } from './hooks/useRerollMission';
export type { RerollMissionRequest, RerollMissionResult } from './hooks/useRerollMission';
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
export {
trackDailyMissionProgress,
trackDailyMissionEvent,
trackMultipleDailyMissionActions,
} from './lib/daily-mission-tracker';
export type {
DailyMission,
DailyMissionAction,
DailyMissionDefinition,
Mission,
TallyMission,
EventMission,
MissionsContent,
DailyMissionsState,
} from './lib/daily-missions';
// Progression
export {
xpToLevel,
levelToXp,
xpProgress,
xpToNextLevel,
getUnlocks,
buildXpTagUpdates,
MAX_LEVEL,
} from '@/blobbi/core/lib/progression';
export type { Unlocks } from '@/blobbi/core/lib/progression';
// Missions content model
export {
parseProfileContent,
serializeProfileContent,
isMissionComplete,
isTallyMission,
isEventMission,
missionProgress,
} from '@/blobbi/core/lib/missions';
export type { ProfileContent } from '@/blobbi/core/lib/missions';
// Item cooldown
export { isItemOnCooldown, setItemCooldown, subscribeCooldowns } from './lib/item-cooldown';
export { ITEM_COOLDOWN_SUCCESS_MS, ITEM_COOLDOWN_FAILURE_MS } from './lib/item-cooldown';
export { useItemCooldown } from './hooks/useItemCooldown';
// Action XP
export {
ACTION_XP,
INVENTORY_ACTION_XP,
DIRECT_ACTION_XP,
POOP_CLEANUP_XP,
calculateActionXP,
calculateInventoryActionXP,
applyXPGain,
formatXPGain,
} from './lib/blobbi-xp';
// Streak tracking
export {
calculateStreakUpdate,
+108 -22
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, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
// ─── Action Types ─────────────────────────────────────────────────────────────
/**
* Item-based care actions (use a shop catalog item on the companion)
* Actions that consume inventory items
*/
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
/**
* Direct actions that don't use items.
* These actions affect stats directly without selecting a shop item.
* Non-inventory actions that don't consume items
* These actions affect stats directly without using shop items.
*/
export type DirectAction = 'play_music' | 'sing';
/**
* All Blobbi actions (item-based + direct)
* All Blobbi actions (inventory + direct)
*/
export type BlobbiAction = InventoryAction | DirectAction;
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
};
/**
* Action metadata for UI display (item-based care actions)
* Action metadata for UI display (inventory 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 (no item required)
* Action metadata for direct actions (non-inventory)
*/
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;
}
// ─── Item Helpers ─────────────────────────────────────────────────────────────
// ─── Inventory Helpers ────────────────────────────────────────────────────────
/**
* Resolved catalog item with shop metadata
* Resolved inventory item with shop metadata
*/
export interface ResolvedInventoryItem {
itemId: string;
@@ -285,7 +285,7 @@ export interface ResolvedInventoryItem {
}
/**
* Options for filtering catalog items by action
* Options for filtering inventory by action
*/
export interface FilterInventoryOptions {
/** Companion stage - used to filter items by egg-compatible effects */
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
}
/**
* Get all available items for an action type from the shop catalog.
* Items are abilities/tools no inventory ownership is required.
* Filter inventory items by action type.
* Returns resolved items with shop metadata.
*
* Filtering rules:
* - Only items matching the action's item type are included
@@ -304,20 +304,22 @@ 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 shopItem of allItems) {
for (const storageItem of storage) {
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
if (shopItem.type !== allowedType) continue;
if (storageItem.quantity <= 0) continue;
// Shell Repair Kit: only show for eggs in medicine modal
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
continue;
}
@@ -332,8 +334,8 @@ export function filterInventoryByAction(
}
result.push({
itemId: shopItem.id,
quantity: Infinity,
itemId: storageItem.itemId,
quantity: storageItem.quantity,
name: shopItem.name,
icon: shopItem.icon,
type: shopItem.type,
@@ -374,7 +376,7 @@ export function decrementStorageItem(
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
/**
* Stages that can use general items (food, toys, hygiene)
* Stages that can use general inventory items (food, toys, hygiene)
*/
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
@@ -407,14 +409,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 item action.
* Check if a companion can use a specific inventory action.
*
* Note: This function no longer hard-blocks egg actions at the domain layer.
* UI visibility is handled separately by `isActionVisibleForStage()`.
* The domain layer allows all actions - UI chooses what to show.
*/
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
// All stages can technically use all item actions at the domain layer.
// All stages can technically use all inventory actions at the domain layer.
// UI filtering determines what actions are shown to users.
return true;
}
@@ -440,7 +442,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
}
/**
* Check if a companion can use general items (feed, play, clean).
* Check if a companion can use general inventory items (feed, play, clean).
* Eggs cannot use food, toys, or hygiene items.
* @deprecated Use canUseAction(companion, action) for action-specific checks
*/
@@ -545,4 +547,88 @@ export function previewCleanForEgg(
return results;
}
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
/** Enable debug logging in development only */
const DEBUG_INTERACTION_TASK = import.meta.env.DEV;
/**
* Result of incrementing interaction task tags
*/
export interface IncrementInteractionResult {
/** Updated tags array */
updatedTags: string[][];
/** New interaction count after increment */
newCount: number;
/** Whether the task is now complete */
isCompleted: boolean;
/** Previous count before increment */
previousCount: number;
}
/**
* Increment the interaction task counter in the tags array.
*
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
* to track progress on interaction tasks for both hatch and evolve.
*
* CRITICAL: This function is called during actual user actions (not retroactive sync).
* It always increments by 1 because each call represents a real interaction.
*
* Tag format:
* - Progress: ["task", "interactions:N"]
* - Completion: ["task_completed", "interactions"]
*
* Idempotency notes:
* - This is NOT idempotent by design - each call = one interaction
* - Duplicate task_completed tags are prevented by filtering before add
* - Multiple task:interactions tags are prevented by filtering before add
*
* @param currentTags - Current tags array from the Blobbi state
* @param requiredInteractions - Threshold for completion (7 for hatch, 21 for evolve)
* @returns Updated tags array with incremented interaction count
*/
export function incrementInteractionTaskTags(
currentTags: string[][],
requiredInteractions: number
): IncrementInteractionResult {
// Get current interaction count from task tags
const interactionTag = currentTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const previousCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = previousCount + 1;
// Check if already completed (task_completed tag exists)
const alreadyCompleted = currentTags.some(tag =>
tag[0] === 'task_completed' && tag[1] === 'interactions'
);
// Remove old interaction task tag (prevent duplicates) and add new one
let updatedTags = currentTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached required count AND not already marked
const isCompleted = newCount >= requiredInteractions;
if (isCompleted && !alreadyCompleted) {
// Only add if not already present (handled by filter, but double-check)
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
if (DEBUG_INTERACTION_TASK) {
console.log('[InteractionTask] Increment:', {
previousCount,
newCount,
requiredInteractions,
isCompleted,
alreadyCompleted,
addedCompletionTag: isCompleted && !alreadyCompleted,
});
}
return { updatedTags, newCount, isCompleted, previousCount };
}
+11 -14
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
* - Item actions (feed, play, clean, medicine) give varied XP per action type
* - Direct actions (sing, play_music) give moderate XP
* - Direct actions (sing, play_music) give moderate XP as they're free
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
* - XP accumulates across all life stages and never resets
*/
@@ -17,18 +17,19 @@ import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-actio
// ─── XP Values by Action ──────────────────────────────────────────────────────
/**
* Base XP values for item-based care actions (feed, play, clean, medicine).
* Base XP values for inventory actions (feed, play, clean, medicine).
* These actions consume items from the player's storage.
*/
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
feed: 5, // Feeding is common and essential - moderate XP
play: 8, // Playing toys provides good interaction - higher XP
clean: 6, // Hygiene maintenance is important - moderate-high XP
medicine: 10, // Medicine is critical - highest item XP
medicine: 10, // Medicine is costly and critical - highest inventory XP
};
/**
* Base XP values for direct actions (play_music, sing).
* These actions don't require selecting an item.
* These actions don't consume items - they're free activities.
*/
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
play_music: 7, // Playing music is engaging - good XP
@@ -44,11 +45,6 @@ export const ACTION_XP: Record<BlobbiAction, number> = {
...DIRECT_ACTION_XP,
};
/**
* XP awarded for cleaning up poop.
*/
export const POOP_CLEANUP_XP = 5;
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
/**
@@ -62,10 +58,11 @@ export function calculateActionXP(action: BlobbiAction): number {
}
/**
* Calculate XP gain for an item-based care action.
* Calculate total XP gain for using multiple items.
* Each item use counts as a separate action for XP purposes.
*
* @param action - The action performed
* @param quantity - Number of times performed (always 1 in current usage)
* @param quantity - Number of items used (defaults to 1)
* @returns Total XP points earned
*/
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
@@ -91,8 +88,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 (always 1 in current usage)
* @returns Object with xpGained and quantity
* @param quantity - Number of times the action was performed (for inventory actions)
* @returns Object with xpGained and total quantity
*/
export function getXPGainSummary(
action: BlobbiAction,
+73 -131
View File
@@ -1,167 +1,109 @@
/**
* Daily Mission Tracker - Standalone progress tracking utility
*
* Provides a way to record daily mission progress from anywhere
* (hooks, event handlers, etc.) without requiring React context.
*
* Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the
* persistent source of truth. Completed missions are persisted by
* `useAwardDailyXp`; intermediate progress resets on page refresh.
*
* Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render.
*
* This module provides a simple way to track daily mission progress
* without requiring React hooks or context. It directly manipulates
* localStorage for immediate persistence.
*
* This approach allows action hooks (which may be called outside of
* the daily missions hook context) to record progress.
*/
import type { MissionsContent } from '@/blobbi/core/lib/missions';
import type { DailyMissionAction } from './daily-missions';
import {
type DailyMissionsState,
type DailyMissionAction,
getTodayDateString,
needsDailyReset,
createDailyMissionsContent,
trackTally,
trackEvent,
trackEvolutionTally,
trackEvolutionEvent,
createDailyMissionsState,
updateMissionProgress,
} from './daily-missions';
// ─── In-Memory Session Store ──────────────────────────────────────────────────
// ─── Constants ────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'blobbi:daily-missions';
// ─── Storage Utilities ────────────────────────────────────────────────────────
/**
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
* Cleared on page refresh (intentional kind 11125 is the persistent store).
* Read the current daily missions state from localStorage
*/
const sessionStore = new Map<string, MissionsContent>();
function key(pubkey: string | undefined): string {
return pubkey ?? '';
function readState(): DailyMissionsState | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function ensureCurrent(pubkey?: string): MissionsContent {
const current = sessionStore.get(key(pubkey));
if (!needsDailyReset(current)) return current!;
const fresh = createDailyMissionsContent(
getTodayDateString(),
current?.evolution ?? [],
pubkey,
);
sessionStore.set(key(pubkey), fresh);
return fresh;
/**
* Write the daily missions state to localStorage
*/
function writeState(state: DailyMissionsState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('[DailyMissionTracker] Failed to write state:', error);
}
}
function notify(detail?: Record<string, unknown>): void {
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
/**
* Ensure we have a valid state for today, creating one if necessary
*/
function ensureCurrentState(pubkey?: string): DailyMissionsState {
const current = readState();
if (needsDailyReset(current)) {
const previousCoins = current?.totalCoinsEarned ?? 0;
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
writeState(newState);
return newState;
}
return current!;
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* Record a tally-based action (feed, clean, interact, etc.).
* Record progress for a daily mission action.
* This function can be called from anywhere (hooks, event handlers, etc.)
* and will immediately persist to localStorage.
*
* @param action - The action type that was performed
* @param count - Number of times the action was performed (default: 1)
* @param pubkey - Optional user pubkey for personalized mission selection
*/
export function trackDailyMissionProgress(
action: DailyMissionAction,
count: number = 1,
pubkey?: string,
pubkey?: string
): void {
const current = ensureCurrent(pubkey);
const updated = trackTally(current, action, count);
sessionStore.set(key(pubkey), updated);
notify({ action, count });
const current = ensureCurrentState(pubkey);
const updated = updateMissionProgress(current, action, count);
writeState(updated);
// Dispatch a custom event so React components can re-render if needed
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
}
/**
* Record an event-based action (take_photo, etc.) with its Nostr event ID.
*/
export function trackDailyMissionEvent(
action: DailyMissionAction,
eventId: string,
pubkey?: string,
): void {
const current = ensureCurrent(pubkey);
const updated = trackEvent(current, action, eventId);
sessionStore.set(key(pubkey), updated);
notify({ action, eventId });
}
/**
* Track multiple tally actions at once.
* Convenience function to track multiple actions at once.
* Useful when an action should count toward multiple missions.
*
* @param actions - Array of actions to track
* @param pubkey - Optional user pubkey
*/
export function trackMultipleDailyMissionActions(
actions: DailyMissionAction[],
pubkey?: string,
pubkey?: string
): void {
let current = ensureCurrent(pubkey);
let current = ensureCurrentState(pubkey);
for (const action of actions) {
current = trackTally(current, action, 1);
current = updateMissionProgress(current, action, 1);
}
sessionStore.set(key(pubkey), current);
notify({ actions });
}
// ─── Evolution Mission Tracking ───────────────────────────────────────────────
/**
* Increment tally for an evolution mission (e.g. interactions).
* No-ops if pubkey missing or session store empty.
*/
export function trackEvolutionMissionTally(
missionId: string,
count: number = 1,
pubkey?: string,
): void {
const current = sessionStore.get(key(pubkey));
if (!current) return;
const updated = trackEvolutionTally(current, missionId, count);
sessionStore.set(key(pubkey), updated);
notify({ evolution: true, missionId, count });
}
/**
* Append a Nostr event ID to an evolution mission (e.g. create_theme).
* Deduplicates by event ID. No-ops if pubkey missing or session store empty.
*/
export function trackEvolutionMissionEvent(
missionId: string,
eventId: string,
pubkey?: string,
): void {
const current = sessionStore.get(key(pubkey));
if (!current) return;
const updated = trackEvolutionEvent(current, missionId, eventId);
sessionStore.set(key(pubkey), updated);
notify({ evolution: true, missionId, eventId });
}
// ─── Storage Access ──────────────────────────────────────────────────────────
/** Read current session state for a pubkey. */
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
return sessionStore.get(key(pubkey));
}
/**
* Ensure the session store has an entry for the given pubkey.
* If the store is empty or needs a daily reset, a fresh entry is created.
* Returns the current (possibly newly-created) MissionsContent.
*
* Use this before writing evolution missions into the store, to avoid
* silent no-ops when the store hasn't been hydrated yet.
*/
export function ensureSessionStore(pubkey?: string): MissionsContent {
return ensureCurrent(pubkey);
}
/** Write state to session store for a pubkey. */
export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void {
sessionStore.set(key(pubkey), missions);
}
/**
* Hydrate the session store from kind 11125 persisted data.
* Called once on mount / account switch when the session store is empty.
* No-op if the store already has data for this pubkey.
*/
export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void {
if (sessionStore.has(pubkey)) return;
sessionStore.set(pubkey, missions);
writeState(current);
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
}
File diff suppressed because it is too large Load Diff
@@ -1,167 +0,0 @@
/**
* Evolution Missions - Static definitions for hatch and evolve tasks.
*
* These are the lifecycle tasks that gate stage transitions (eggbaby, babyadult).
* Progress is tracked in `MissionsContent.evolution[]` on kind 11125, using the
* same TallyMission / EventMission model as daily missions.
*
* Unlike daily missions, evolution missions:
* - Are populated when incubation/evolution starts
* - Are cleared when the stage transition completes (or is cancelled)
* - Are NOT deterministically seeded the full set is always used
*/
import type { Mission, TallyMission, EventMission } from '@/blobbi/core/lib/missions';
// ─── Shared Helpers ──────────────────────────────────────────────────────────
/** Find an evolution mission by ID in the given array. */
export function findEvolutionMission(evolution: Mission[], id: string): Mission | undefined {
return evolution.find((m) => m.id === id);
}
// ─── Tracking Type ───────────────────────────────────────────────────────────
export type EvolutionTrackingType = 'tally' | 'event';
// ─── Definition ──────────────────────────────────────────────────────────────
export interface EvolutionMissionDefinition {
/** Unique identifier (matches Mission.id) */
id: string;
/** Display title */
title: string;
/** Description shown in the UI */
description: string;
/** Number of times the action must be performed / events collected */
target: number;
/** Whether this mission tracks a counter or event IDs */
tracking: EvolutionTrackingType;
/** UI action hint */
action?: 'navigate' | 'open_modal' | 'external_link';
/** Target for the action */
actionTarget?: string;
/** Button label */
actionLabel?: string;
}
// ─── Hatch Mission Pool ──────────────────────────────────────────────────────
export const HATCH_MISSIONS: readonly EvolutionMissionDefinition[] = [
{
id: 'create_theme',
title: 'Create Theme',
description: 'Create a custom theme for your profile',
target: 1,
tracking: 'event',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
},
{
id: 'color_moment',
title: 'Color Moment',
description: 'Share a color moment on espy',
target: 1,
tracking: 'event',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
},
{
id: 'create_post',
title: 'Create Post',
description: 'Share a post with the #blobbi hashtag',
target: 1,
tracking: 'event',
action: 'open_modal',
actionTarget: 'blobbi_post',
actionLabel: 'Create Post',
},
{
id: 'interactions',
title: 'Interact with Blobbi',
description: 'Care for your Blobbi 7 times',
target: 7,
tracking: 'tally',
},
] as const;
// ─── Evolve Mission Pool ─────────────────────────────────────────────────────
export const EVOLVE_MISSIONS: readonly EvolutionMissionDefinition[] = [
{
id: 'create_themes',
title: 'Create Themes',
description: 'Create 3 custom themes',
target: 3,
tracking: 'event',
action: 'navigate',
actionTarget: '/themes',
actionLabel: 'Create Theme',
},
{
id: 'color_moments',
title: 'Color Moments',
description: 'Share 3 color moments on espy',
target: 3,
tracking: 'event',
action: 'external_link',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
},
{
id: 'interactions',
title: 'Interact with Blobbi',
description: 'Care for your Blobbi 21 times',
target: 21,
tracking: 'tally',
},
{
id: 'edit_profile',
title: 'Edit Your Profile',
description: 'Update your profile info or customize your profile tabs',
target: 1,
tracking: 'event',
action: 'navigate',
actionTarget: '/settings/profile',
actionLabel: 'Edit Profile',
},
] as const;
// ─── Instantiation ───────────────────────────────────────────────────────────
/** Create a fresh Mission from an evolution definition */
export function createEvolutionMission(def: EvolutionMissionDefinition): Mission {
if (def.tracking === 'event') {
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
}
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
}
/** Create the full set of hatch missions (for starting incubation) */
export function createHatchMissions(): Mission[] {
return HATCH_MISSIONS.map(createEvolutionMission);
}
/** Create the full set of evolve missions (for starting evolution) */
export function createEvolveMissions(): Mission[] {
return EVOLVE_MISSIONS.map(createEvolutionMission);
}
// ─── Constants (re-exported for backward compat) ─────────────────────────────
/** Required interactions to complete the hatch interactions task */
export const HATCH_REQUIRED_INTERACTIONS = 7;
/** Required interactions to complete the evolve interactions task */
export const EVOLVE_REQUIRED_INTERACTIONS = 21;
/** Required themes for evolve task */
export const EVOLVE_REQUIRED_THEMES = 3;
/** Required color moments for evolve task */
export const EVOLVE_REQUIRED_COLOR_MOMENTS = 3;
/** Stat threshold for evolve dynamic task (all stats >= 80) */
export const EVOLVE_STAT_THRESHOLD = 80;
-68
View File
@@ -1,68 +0,0 @@
/**
* Centralized item-use cooldown tracking.
*
* Module-level singleton shared by every item-use path
* (dashboard, companion layer, shop modal, falling items).
*
* Keyed by item type ID (e.g. "food_apple"), not instance IDs.
* Separate durations for success (short) and failure (longer).
* Built-in subscriber system for React via useSyncExternalStore.
*/
// ─── Configuration ────────────────────────────────────────────────────────────
/** Cooldown after a successful item use (ms). */
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
/** Cooldown after a failed item use (ms). */
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
// ─── Singleton State ──────────────────────────────────────────────────────────
interface CooldownEntry {
expiresAt: number;
timerId: ReturnType<typeof setTimeout>;
}
const cooldowns = new Map<string, CooldownEntry>();
const subscribers = new Set<() => void>();
function notify(): void {
subscribers.forEach((cb) => cb());
}
// ─── Public API ───────────────────────────────────────────────────────────────
/** Check whether an item is currently on cooldown. */
export function isItemOnCooldown(itemId: string): boolean {
const entry = cooldowns.get(itemId);
if (!entry) return false;
if (Date.now() >= entry.expiresAt) {
clearTimeout(entry.timerId);
cooldowns.delete(itemId);
return false;
}
return true;
}
/** Put an item on cooldown. Notifies subscribers on start and expiry. */
export function setItemCooldown(itemId: string, success: boolean): void {
const prev = cooldowns.get(itemId);
if (prev) clearTimeout(prev.timerId);
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
const timerId = setTimeout(() => {
cooldowns.delete(itemId);
notify();
}, ms);
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
notify();
}
/** Subscribe to cooldown state changes. Returns unsubscribe function. */
export function subscribeCooldowns(callback: () => void): () => void {
subscribers.add(callback);
return () => { subscribers.delete(callback); };
}
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
}
try {
const result = await contextUseItem(item.id, action);
const result = await contextUseItem(item.id, action, 1);
if (result.success) {
if (import.meta.env.DEV) {
@@ -19,12 +19,11 @@ 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/useEmotionDev';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
@@ -251,8 +250,9 @@ export function BlobbiCompanionVisual({
style={{ transformOrigin: 'center bottom' }}
>
{companion.stage === 'egg' ? (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<BlobbiStageVisual
companion={companion as unknown as BlobbiCompanion}
companion={companion as any}
size="sm"
animated={false}
className="size-full"
+1 -1
View File
@@ -107,7 +107,7 @@ export const DEFAULT_COMPANION_CONFIG: CompanionConfig = {
pause2Duration: 100, // Short pause before falling
// Truly stuck behavior
trulyStuckChance: 0.10, // 10% chance to be truly stuck (needs user drag)
trulyStuckChance: 0.30, // 30% chance to be truly stuck (needs user drag)
fallDuration: 450, // Fall after getting loose
landingDuration: 200, // Brief squash on landing
@@ -4,9 +4,11 @@
* Fetches the current companion data from the user's Blobbonaut profile.
* This is the data layer - it handles fetching and provides companion data.
*
* 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.
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
* useBlobbisCollection. This ensures:
* - Immediate reactivity when stats change (optimistic updates)
* - Projected decay is applied for accurate visual reactions
* - No duplicate queries or stale cache issues
*/
import { useMemo } from 'react';
@@ -30,14 +32,16 @@ interface UseBlobbiCompanionDataResult {
*
* Flow:
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
* 2. Build a dList containing just the currentCompanion (targeted fetch)
* 3. Use useBlobbisCollection with the dList to get the companion
* 2. Build a dList containing just the currentCompanion
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
* 4. Apply projected decay for accurate UI reactions
* 5. Return the companion data with projected stats
*
* Reactivity:
* - Optimistic updates propagate across all blobbi-collection queries
* - Projected decay recalculates every 60 seconds while mounted
* - Uses the same query cache as BlobbiPage (blobbi-collection)
* - When Blobbi state is updated, optimistic updates flow through immediately
* - Projected decay recalculates every 60 seconds
* - No separate query or stale cache issues
*/
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
// Use the shared profile hook - this ensures reactivity when profile changes
@@ -102,7 +102,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection('right');
setTargetX(targetX);
}, [bounds.maxX, motionRef]);
}, [bounds.maxX]);
/**
* Generate a random observation target on screen.
@@ -136,7 +136,7 @@ export function useBlobbiCompanionState({
setState('walking');
setDirection(newDirection);
setTargetX(targetXPos);
}, [bounds, generateObservationTarget, motionRef]);
}, [bounds, generateObservationTarget]);
// 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, motionRef]);
}, [isActive, isSleeping, bounds, state, config, startObservation]);
// Handle reaching target
const onReachedTarget = useCallback(() => {
@@ -255,7 +255,7 @@ export function useBlobbiCompanionState({
clearTimeout(timerRef.current);
}
};
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
// Pause decisions while dragging
// We poll isDragging via interval since motionRef changes don't trigger re-renders
@@ -19,8 +19,9 @@
* idle -> rising -> inspecting -> entering -> complete
*
* Route change behavior:
* - Companion keeps its current position (no re-entry animation)
* - Only initial mount and companion changes trigger entry animations
* - Cancels current entry immediately
* - Waits 1 second
* - Restarts entry for the new page
*/
import { useState, useEffect, useRef, useCallback } from 'react';
@@ -309,11 +310,20 @@ 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) {
// Route changed - companion keeps its position, no re-entry animation.
// Just update the ref so future changes compare against the new path.
} 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);
}
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
/**
* Animation loop for FALL entry.
@@ -15,15 +15,17 @@ 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 './BlobbiActionsContextDef';
} from './BlobbiActionsProvider';
// Re-export types and context from the def module for backward compatibility
// Re-export everything from the provider module for backward compatibility
export {
BlobbiActionsContext,
BlobbiActionsProvider,
type UseItemFunction,
type UseItemResult,
type BlobbiActionsContextValue,
@@ -62,13 +64,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) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
// 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);
return context.registerRef.current(itemId, action, quantity);
}
// Check if fallback can handle it
@@ -86,7 +88,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
if (import.meta.env.DEV) {
console.log('[BlobbiActions] Using fallback item-use hook');
}
return fallbackItemUse.useItem(itemId, action);
return fallbackItemUse.useItem(itemId, action, quantity);
}, [context, fallbackItemUse]);
// Determine canUseItems: true if registered OR fallback can use
@@ -134,14 +136,14 @@ export function useBlobbiActionsRegistration(
useItemRef.current = useItemFn;
// Create a stable wrapper that delegates to the ref
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
if (!useItemRef.current) {
return {
success: false,
error: 'Item use function not available',
};
}
return useItemRef.current(itemId, action);
return useItemRef.current(itemId, action, quantity);
}, []);
// Update refs and notify only when canUseItems actually changes
@@ -1,75 +0,0 @@
/**
* 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,13 +10,75 @@
* BlobbiPage, both of which are lazy-loaded.
*/
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
import {
BlobbiActionsContext,
type UseItemFunction,
type BlobbiActionsContextInternal,
} from './BlobbiActionsContextDef';
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);
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -1,24 +1,26 @@
/**
* HangingItems
*
* Displays available items as hanging elements from the top of the screen.
* Displays inventory 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 = catalog items available for the selected action
* - Hanging items = available inventory that can still be released
* - 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 to the ground
* - Click releases item: one instance falls, remaining quantity stays hanging
* - 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
@@ -117,7 +119,7 @@ interface HangingItemsProps {
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
/**
* Callback when an item is collected by Blobbi (contact).
* @deprecated Use onItemUse instead for proper item-use flow.
* @deprecated Use onItemUse instead for proper item consumption flow.
*/
onItemCollected?: (item: CompanionItem) => void;
/**
@@ -154,7 +156,7 @@ const HANGING_CONFIG = {
baseFallDistance: 500,
/** Ground offset from bottom of viewport */
defaultGroundOffset: 40,
/** Size of badge (unused — kept for config consistency) */
/** Size of quantity badge */
badgeSize: 20,
/** Size of landed item hitbox for contact detection */
landedItemSize: 40,
@@ -404,7 +406,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);
@@ -564,7 +566,7 @@ export function HangingItems({
// Start the loop
animationRef.current = requestAnimationFrame(animate);
}, [calculateFallDuration]);
}, []);
// Cleanup animation on unmount
useEffect(() => {
@@ -668,7 +670,7 @@ export function HangingItems({
});
// Also remove from zone tracking
itemsInZoneRef.current.delete(instanceId);
// Decrement the released count for this item type (instance removed from screen)
// Decrement the released count for this item type (since the instance is now consumed)
setReleasedCountByItemId(prev => {
const next = new Map(prev);
const currentCount = next.get(item.id) || 0;
@@ -983,9 +985,15 @@ export function HangingItems({
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
};
// All items are always visible — they are abilities, not consumable inventory.
// No quantity filtering needed.
const hangingItems = items;
// 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);
// Should we render the hanging container?
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
@@ -1025,7 +1033,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 available
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
</p>
</div>
</div>
@@ -1094,8 +1102,8 @@ export function HangingItems({
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
}}
onClick={() => handleItemClick(item, itemX)}
title={item.name}
aria-label={`${item.name}. Click to release.`}
title={`${item.name} (x${item.quantity})`}
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
>
{/* Item emoji */}
<span
@@ -1106,6 +1114,24 @@ 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 shop catalog items optimized for rendering.
* This is a simplified view of inventory 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;
/** Availability (always Infinity — items are reusable abilities) */
/** Quantity available in inventory */
quantity: number;
/** Item effects when used */
effect?: ItemEffect;
@@ -27,10 +27,13 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
createStorageTags,
parseBlobbiEvent,
isValidBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
@@ -38,6 +41,7 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import {
applyItemEffects,
decrementStorageItem,
canUseAction,
canUseItemForStage,
getStageRestrictionMessage,
@@ -45,14 +49,17 @@ import {
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
incrementInteractionTaskTags,
type InventoryAction,
ACTION_METADATA,
} from '@/blobbi/actions/lib/blobbi-action-utils';
import { trackMultipleDailyMissionActions, trackEvolutionMissionTally } from '@/blobbi/actions/lib/daily-mission-tracker';
import { trackMultipleDailyMissionActions } from '@/blobbi/actions/lib/daily-mission-tracker';
import type { DailyMissionAction } from '@/blobbi/actions/lib/daily-missions';
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 './BlobbiActionsContextDef';
import type { UseItemFunction } from './BlobbiActionsProvider';
// ─── Configuration ────────────────────────────────────────────────────────────
@@ -119,7 +126,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
const queryClient = useQueryClient();
// Fetch profile if not provided
const { profile: fetchedProfile } = useBlobbonautProfile();
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
const profile = options.profile ?? fetchedProfile;
// Per-item cooldown tracking (ref to avoid re-renders)
@@ -225,14 +232,16 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
// Core mutation for using items (always uses once)
// Core mutation for using items
const mutation = useMutation({
mutationFn: async ({
itemId,
action,
quantity = 1,
}: {
itemId: string;
action: InventoryAction;
quantity?: number;
}): Promise<{ statsChanged: Record<string, number> }> => {
// ─── Validation ───
if (!user?.pubkey) {
@@ -250,6 +259,11 @@ 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);
@@ -269,6 +283,15 @@ 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');
@@ -296,13 +319,17 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
// Start with decayed stats as the base
const statsAfterDecay = decayResult.stats;
// ─── Apply Item Effects (single use) ───
// ─── Apply Item Effects ───
const isEggCompanion = companion.stage === 'egg';
const statsUpdate: Record<string, string> = {};
const statsChanged: Record<string, number> = {};
if (isEggCompanion && action === 'medicine') {
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
const healthDelta = shopItem.effect.health ?? 0;
let currentHealth = statsAfterDecay.health ?? 0;
for (let i = 0; i < quantity; i++) {
currentHealth = applyStat(currentHealth, healthDelta);
}
statsUpdate.health = currentHealth.toString();
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
@@ -312,8 +339,15 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else if (isEggCompanion && action === 'clean') {
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
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);
}
statsUpdate.hygiene = currentHygiene.toString();
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
@@ -328,8 +362,11 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
statsUpdate.hunger = '100';
statsUpdate.energy = '100';
} else {
// Normal stats application for baby/adult — apply once
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
// Normal stats application for baby/adult
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
for (let i = 0; i < quantity; i++) {
currentStats = applyItemEffects(currentStats, shopItem.effect);
}
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
@@ -350,11 +387,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
// ─── Update Blobbi State Event (kind 31124) ───
const nowStr = now.toString();
// If incubating or evolving, increment the interaction counter in evolution missions
// Handle interaction counter for tasks
const companionState = companion.state;
const updatedTags = companion.allTags;
if (companionState === 'incubating' || companionState === 'evolving') {
trackEvolutionMissionTally('interactions', 1, user?.pubkey);
let updatedTags = companion.allTags;
if (companionState === 'incubating') {
updatedTags = incrementInteractionTaskTags(companion.allTags, HATCH_REQUIRED_INTERACTIONS).updatedTags;
} else if (companionState === 'evolving') {
updatedTags = incrementInteractionTaskTags(companion.allTags, EVOLVE_REQUIRED_INTERACTIONS).updatedTags;
}
// Get streak updates (will only update if needed based on day)
@@ -375,19 +414,36 @@ 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 ───
// Items are free to use — no storage decrement needed.
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
return { statsChanged };
},
onSuccess: (_, { itemId, action }) => {
onSuccess: (_, { itemId, action, quantity = 1 }) => {
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'} on your Blobbi.`,
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
});
// Track daily mission progress
@@ -412,7 +468,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
});
// Wrapper function that matches UseItemFunction signature and includes cooldown check
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
// Check cooldown first
if (isItemOnCooldown(itemId)) {
if (import.meta.env.DEV) {
@@ -425,7 +481,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
}
try {
const result = await mutation.mutateAsync({ itemId, action });
const result = await mutation.mutateAsync({ itemId, action, quantity });
return {
success: true,
statsChanged: result.statsChanged,
@@ -69,13 +69,14 @@ 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) return;
if (!parsed) {
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
return;
}
// Optimistically update ALL blobbi-collection queries for this user.
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
// partial matching to find all entries regardless of dList shape.
// 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],
@@ -89,6 +90,9 @@ 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,7 +18,8 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
import type {
@@ -67,10 +68,7 @@ interface UseCompanionActionMenuResult {
}
/**
* 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.
* Resolve inventory items for a specific action/category.
*
* Uses the centralized `canUseItemForStage` function to ensure consistent
* stage-based filtering across all UIs:
@@ -82,6 +80,7 @@ interface UseCompanionActionMenuResult {
* filters out all egg-only items from the companion interaction system.
*/
function resolveItemsForAction(
storage: StorageItem[],
action: CompanionMenuAction,
stage: 'egg' | 'baby' | 'adult'
): CompanionItem[] {
@@ -90,10 +89,13 @@ function resolveItemsForAction(
// Sleep action has no items
if (!category) return [];
const allItems = getLiveShopItems();
const items: CompanionItem[] = [];
for (const shopItem of allItems) {
for (const storageItem of storage) {
if (storageItem.quantity <= 0) continue;
const shopItem = getShopItemById(storageItem.itemId);
if (!shopItem) continue;
if (shopItem.type !== category) continue;
// Use centralized stage-based filtering
@@ -102,17 +104,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(shopItem.id, stage);
const usability = canUseItemForStage(storageItem.itemId, stage);
if (!usability.canUse) {
continue;
}
items.push({
id: shopItem.id,
id: storageItem.itemId,
name: shopItem.name,
emoji: shopItem.icon,
category: shopItem.type,
quantity: Infinity,
quantity: storageItem.quantity,
effect: shopItem.effect,
});
}
@@ -195,8 +197,8 @@ export function useCompanionActionMenu({
return;
}
// Resolve items for this action from the catalog (not inventory)
const items = resolveItemsForAction(action, stage);
// Resolve items for this action
const items = resolveItemsForAction(profile.storage, action, stage);
setMenuState(prev => ({
...prev,
@@ -42,6 +42,7 @@ export interface ItemUseResult {
export type UseItemCallback = (
itemId: string,
action: InventoryAction,
quantity: number
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
/**
@@ -66,14 +67,14 @@ export interface UseCompanionItemUseResult {
isUsingItem: boolean;
/** Get the action type for an item category */
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
/** Get the care action for a menu action */
/** Get the inventory action for a menu action */
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
}
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* Map item categories to care actions.
* Map item categories to inventory actions.
* This is the canonical mapping for how items are used.
*/
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
@@ -84,14 +85,14 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
};
/**
* Map menu actions to item-based care actions (they match by design).
* Map menu actions to inventory 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 item-based
sleep: null, // Sleep is a special action, not an inventory action
};
// ─── Hook Implementation ──────────────────────────────────────────────────────
@@ -107,8 +108,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
* Usage:
* ```tsx
* const { useItem, isUsingItem } = useCompanionItemUse({
* onUseItem: async (itemId, action) => {
* return await executeUseItem({ itemId, action });
* onUseItem: async (itemId, action, qty) => {
* return await executeUseItem({ itemId, action, quantity: qty });
* },
* onSuccess: (result) => removeItemFromScreen(result.item),
* onFailure: (result) => keepItemOnScreen(result.item),
@@ -133,7 +134,7 @@ export function useCompanionItemUse({
}, []);
/**
* Get the care action for a menu action.
* Get the inventory action for a menu action.
*/
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
@@ -186,7 +187,7 @@ export function useCompanionItemUse({
try {
// Execute the use callback
const useResult = await onUseItem(item.id, inventoryAction);
const useResult = await onUseItem(item.id, inventoryAction, 1);
if (useResult.success) {
const result: ItemUseResult = {
+18 -97
View File
@@ -1,5 +1,4 @@
import { useCallback } from 'react';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -9,18 +8,12 @@ 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,
@@ -59,6 +52,10 @@ 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;
}
/**
@@ -79,11 +76,6 @@ export interface EnsureCanonicalResult {
* to avoid restoring stale/legacy values after migration.
*/
profileAllTags: string[][];
/**
* The previous profile event, for passing as `prev` to publishEvent
* to preserve `published_at` on replaceable events.
*/
profileEvent: NostrEvent;
/**
* The latest profile storage to use.
* Use this as the base for storage modifications.
@@ -119,7 +111,6 @@ export interface EnsureCanonicalResult {
* ```
*/
export function useBlobbiMigration() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -143,6 +134,8 @@ export function useBlobbiMigration() {
updateProfileEvent,
updateCompanionEvent,
updateStoredSelectedD,
invalidateCompanion,
invalidateProfile,
} = options;
if (!user?.pubkey) {
@@ -197,8 +190,7 @@ export function useBlobbiMigration() {
tags: profileTags,
});
// Update query caches (optimistic — no invalidation needed since we
// fetch fresh from relays before every mutation)
// Update query caches
updateProfileEvent(profileEvent);
updateCompanionEvent(canonicalEvent);
@@ -208,6 +200,10 @@ 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.`,
@@ -241,102 +237,29 @@ 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. 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
* 1. Check if Blobbi is legacy
* 2. If legacy: migrate Blobbi
* 3. Return the resolved canonical Blobbi
*
* All interaction handlers should call this before publishing events.
*/
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
options: EnsureCanonicalOptions
): Promise<EnsureCanonicalResult | null> => {
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;
const { companion, profile } = options;
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
// Use fresh data in migration options
const migrationOptions = { ...options, companion, profile };
const migrationResult = await migrateLegacyBlobbi(migrationOptions);
const migrationResult = await migrateLegacyBlobbi(options);
if (!migrationResult) {
// Migration failed, cannot proceed with action
@@ -352,22 +275,20 @@ export function useBlobbiMigration() {
allTags: migrationResult.event.tags,
content: migrationResult.event.content,
profileAllTags: migrationResult.profileTags,
profileEvent: migrationResult.profileEvent,
profileStorage: migrationResult.profileStorage,
};
}
// Companion is already canonical, return fresh data
// Companion is already canonical, return profile as-is
return {
wasMigrated: false,
companion,
allTags: companion.allTags,
content: companion.event.content,
profileAllTags: profile.allTags,
profileEvent: profile.event,
profileStorage: profile.storage,
};
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
}, [migrateLegacyBlobbi]);
return {
/** Migrate a legacy Blobbi to canonical format */
+61 -95
View File
@@ -6,7 +6,6 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
KIND_BLOBBI_STATE,
BLOBBI_ECOSYSTEM_NAMESPACE,
isValidBlobbiEvent,
parseBlobbiEvent,
type BlobbiCompanion,
@@ -27,90 +26,62 @@ function chunkArray<T>(array: T[], size: number): T[][] {
}
/**
* 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).
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
*
* Features:
* - Fetches ALL pets by d-tag list (no limit: 1)
* - Chunks large d-lists into multiple queries for relay compatibility
* - Keeps only the newest event per d-tag
* - Returns both a lookup record and array of companions
* - Provides invalidation and optimistic update helpers
*/
export function useBlobbisCollection(dList?: string[] | undefined) {
export function useBlobbisCollection(dList: string[] | undefined) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// 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)
// Create a stable query key based on sorted d-tags
const sortedDList = useMemo(() => {
if (mode === 'all' || !dList || dList.length === 0) return null;
if (!dList || dList.length === 0) return null;
return [...dList].sort();
}, [mode, dList]);
}, [dList]);
// Query key segment: 'all' for fetch-all mode, comma-joined d-tags for dlist mode
const queryKeySegment = mode === 'all' ? 'all' : (sortedDList?.join(',') ?? '');
const queryKeyDTags = sortedDList?.join(',') ?? '';
// Main query to fetch companions from relays
// Main query to fetch all companions from relays
const query = useQuery({
queryKey: ['blobbi-collection', user?.pubkey, queryKeySegment],
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
console.log('[useBlobbisCollection] No pubkey, returning empty');
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
return { companionsByD: {}, companions: [] };
}
let allEvents: NostrEvent[];
// Log the dList we're about to query
console.log('[Blobbi] dList:', sortedDList);
if (mode === 'all') {
// Fetch ALL the user's blobbi events — author is the source of truth
// Chunk the d-list for relay compatibility
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
// Query all chunks in parallel
const allEvents: NostrEvent[] = [];
for (const chunk of chunks) {
const filter = {
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
'#d': chunk,
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
};
console.log('[Blobbi] 31124 query filter (all):', JSON.stringify(filter, null, 2));
// Log the filter immediately before query
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
allEvents = await nostr.query([filter], { signal });
const events = await nostr.query([filter], { signal });
allEvents.push(...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] Chunk returned', events.length, 'events');
}
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
@@ -152,7 +123,7 @@ export function useBlobbisCollection(dList?: string[] | undefined) {
return { companionsByD, companions };
},
enabled: !!user?.pubkey && (mode === 'all' || (!!sortedDList && sortedDList.length > 0)),
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
staleTime: 30_000, // 30 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
@@ -161,51 +132,46 @@ export function useBlobbisCollection(dList?: string[] | undefined) {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// 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).
// Helper to invalidate and refetch after publishing
const invalidate = useCallback(() => {
if (user?.pubkey) {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
queryKey: ['blobbi-collection', user.pubkey, queryKeySegment],
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
});
}
}, [queryClient, user?.pubkey, queryKeySegment]);
}, [queryClient, user?.pubkey, queryKeyDTags]);
// 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).
// Update a single companion event in the query cache (optimistic update)
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
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]);
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
['blobbi-collection', user.pubkey, queryKeyDTags],
(prev) => {
if (!prev) {
return {
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
};
}
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
}
);
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
const companionsByD = query.data?.companionsByD ?? {};
@@ -224,7 +190,7 @@ export function useBlobbisCollection(dList?: string[] | undefined) {
isStale: query.isStale,
/** Query error if any */
error: query.error,
/** Invalidate and refetch the collection (use only when d-tag set changes, not after mutations) */
/** Invalidate and refetch the collection */
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 -18
View File
@@ -288,7 +288,7 @@ export interface BlobbiCompanion {
}
/**
* Stored item in user's profile (from purchases)
* Stored item in user's profile inventory
*/
export interface StorageItem {
itemId: string; // Must match a ShopItem.id
@@ -316,16 +316,8 @@ export interface BlobbonautProfile {
coins: number;
/** Petting level (interaction counter) */
pettingLevel: number;
/** Player lifetime XP (source of truth for progression) */
xp: number;
/** Player level (derived from xp, stored as queryable mirror) */
level: number;
/** Current room the player is in (persisted for cross-session continuity) */
room: string | undefined;
/** Purchased items storage */
/** Purchased items inventory */
storage: StorageItem[];
/** Raw content string for missions JSON */
content: string;
/** All tags preserved for republishing */
allTags: string[][];
}
@@ -990,11 +982,7 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
has: getTagValues(tags, 'has'),
coins: parseNumericTag(tags, 'coins') ?? 0,
pettingLevel: pettingLevelValue,
xp: parseNumericTag(tags, 'xp') ?? 0,
level: parseNumericTag(tags, 'level') ?? 1,
room: getTagValue(tags, 'room') ?? undefined,
storage: parseStorageTags(tags),
content: event.content,
allTags: tags,
};
}
@@ -1152,10 +1140,6 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
*/
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
// Progression tags
'xp', 'level',
// Room persistence
'room',
// Legacy player progress tags (preserved for compatibility)
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
-162
View File
@@ -1,162 +0,0 @@
/**
* Missions Content Model
*
* Defines the JSON shape stored in the kind 11125 content field.
* Two mission categories:
* - daily: reset each day, tally-based or event-based
* - evolution: persist across sessions until stage transition completes
*
* Tally missions track a `count` (no event IDs).
* Event missions track an `events` array of Nostr event IDs.
* Completion is derived: count >= target or events.length >= target.
*/
// ─── Mission Entry Types ─────────────────────────────────────────────────────
/** A mission tracked by a simple counter (feed, clean, interact, etc.) */
export interface TallyMission {
id: string;
target: number;
count: number;
}
/** A mission tracked by Nostr event IDs (post, photo, theme, etc.) */
export interface EventMission {
id: string;
target: number;
events: string[];
}
/** Union of both mission shapes */
export type Mission = TallyMission | EventMission;
/** Type guard: mission tracks events */
export function isEventMission(m: Mission): m is EventMission {
return 'events' in m;
}
/** Type guard: mission tracks a tally */
export function isTallyMission(m: Mission): m is TallyMission {
return 'count' in m;
}
/** Check if a mission is complete */
export function isMissionComplete(m: Mission): boolean {
if (isEventMission(m)) return m.events.length >= m.target;
return m.count >= m.target;
}
/** Get current progress numerator */
export function missionProgress(m: Mission): number {
if (isEventMission(m)) return m.events.length;
return m.count;
}
// ─── Content Shape ───────────────────────────────────────────────────────────
/** The full missions object stored in kind 11125 content JSON */
export interface MissionsContent {
date: string; // YYYY-MM-DD for daily reset detection
daily: Mission[]; // 3 daily missions, reset each day
evolution: Mission[]; // active evolution missions, cleared on stage transition
rerolls: number; // daily rerolls remaining (resets with date)
}
/**
* The top-level content JSON for kind 11125.
* Currently only `missions`. Future keys can be added alongside.
*/
export interface ProfileContent {
missions?: MissionsContent;
}
// ─── Parse / Serialize ───────────────────────────────────────────────────────
/**
* Parse the kind 11125 content field into a typed ProfileContent.
* Returns an empty object for empty/invalid content. Never throws.
*/
export function parseProfileContent(content: string): ProfileContent {
if (!content || !content.trim()) return {};
try {
const raw = JSON.parse(content);
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {};
const result: ProfileContent = {};
if (raw.missions && typeof raw.missions === 'object') {
result.missions = parseMissionsContent(raw.missions);
}
return result;
} catch {
return {};
}
}
/**
* Serialize ProfileContent back to a JSON string for publishing.
* Preserves any unknown top-level keys from the existing content.
*/
export function serializeProfileContent(
existingContent: string,
updates: Partial<ProfileContent>,
): string {
let base: Record<string, unknown> = {};
if (existingContent && existingContent.trim()) {
try {
const parsed = JSON.parse(existingContent);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
base = parsed;
}
} catch {
// corrupt content -- start fresh but don't lose updates
}
}
return JSON.stringify({ ...base, ...updates });
}
// ─── Internal Helpers ────────────────────────────────────────────────────────
function parseMissionsContent(raw: Record<string, unknown>): MissionsContent | undefined {
if (typeof raw.date !== 'string') return undefined;
return {
date: raw.date,
daily: parseMissionArray(raw.daily),
evolution: parseMissionArray(raw.evolution),
rerolls: typeof raw.rerolls === 'number' ? Math.max(0, Math.floor(raw.rerolls)) : 0,
};
}
function parseMissionArray(raw: unknown): Mission[] {
if (!Array.isArray(raw)) return [];
const result: Mission[] = [];
for (const entry of raw) {
const m = parseSingleMission(entry);
if (m) result.push(m);
}
return result;
}
function parseSingleMission(raw: unknown): Mission | undefined {
if (typeof raw !== 'object' || raw === null) return undefined;
const obj = raw as Record<string, unknown>;
if (typeof obj.id !== 'string' || typeof obj.target !== 'number') return undefined;
// Event-based mission
if (Array.isArray(obj.events)) {
return {
id: obj.id,
target: Math.max(1, Math.floor(obj.target)),
events: obj.events.filter((e): e is string => typeof e === 'string'),
};
}
// Tally-based mission
if (typeof obj.count === 'number') {
return {
id: obj.id,
target: Math.max(1, Math.floor(obj.target)),
count: Math.max(0, Math.floor(obj.count)),
};
}
return undefined;
}
-118
View File
@@ -1,118 +0,0 @@
/**
* Progression System
*
* Player-level XP and leveling. XP lives on kind 11125 as tags.
* Level is derived from XP. Unlocks are derived from level.
* No nested objects, no JSON content, no multi-game maps.
*/
// ─── XP Thresholds ───────────────────────────────────────────────────────────
/**
* Cumulative XP required to reach each level.
* Index 0 = level 1 (0 XP), index 1 = level 2 (100 XP), etc.
* Levels beyond the table cap at the last entry.
*/
const XP_THRESHOLDS: readonly number[] = [
0, // Level 1
100, // Level 2
250, // Level 3
500, // Level 4
850, // Level 5
1300, // Level 6
1900, // Level 7
2650, // Level 8
3600, // Level 9
4800, // Level 10
6300, // Level 11
8100, // Level 12
10200, // Level 13
12700, // Level 14
15600, // Level 15
19000, // Level 16
23000, // Level 17
27600, // Level 18
33000, // Level 19
39200, // Level 20
];
export const MAX_LEVEL = XP_THRESHOLDS.length;
// ─── Level Calculation ───────────────────────────────────────────────────────
/**
* Derive level from cumulative XP.
* Walks the threshold table to find the highest level the XP qualifies for.
*/
export function xpToLevel(xp: number): number {
const safeXp = Math.max(0, Math.floor(xp));
for (let i = XP_THRESHOLDS.length - 1; i >= 0; i--) {
if (safeXp >= XP_THRESHOLDS[i]) {
return i + 1; // levels are 1-indexed
}
}
return 1;
}
/**
* Get the cumulative XP required to reach a given level.
*/
export function levelToXp(level: number): number {
const idx = Math.max(0, Math.min(level - 1, XP_THRESHOLDS.length - 1));
return XP_THRESHOLDS[idx];
}
/**
* Get progress within the current level as a fraction [0, 1].
* Returns 1 at max level.
*/
export function xpProgress(xp: number): number {
const level = xpToLevel(xp);
if (level >= MAX_LEVEL) return 1;
const currentThreshold = XP_THRESHOLDS[level - 1];
const nextThreshold = XP_THRESHOLDS[level];
const range = nextThreshold - currentThreshold;
if (range <= 0) return 1;
return Math.min(1, (xp - currentThreshold) / range);
}
/**
* XP remaining to reach the next level. 0 at max level.
*/
export function xpToNextLevel(xp: number): number {
const level = xpToLevel(xp);
if (level >= MAX_LEVEL) return 0;
return XP_THRESHOLDS[level] - xp;
}
// ─── Unlocks ─────────────────────────────────────────────────────────────────
export interface Unlocks {
/** Maximum number of Blobbis the player can own */
maxBlobbis: number;
}
/**
* Derive unlocks from level. Pure function, no stored state.
*/
export function getUnlocks(level: number): Unlocks {
let maxBlobbis = 1;
if (level >= 5) maxBlobbis = 2;
if (level >= 10) maxBlobbis = 3;
if (level >= 15) maxBlobbis = 4;
if (level >= 20) maxBlobbis = 5;
return { maxBlobbis };
}
// ─── Tag Helpers ─────────────────────────────────────────────────────────────
/**
* Build XP and level tag updates for kind 11125.
* Level is always derived from XP -- never set independently.
*/
export function buildXpTagUpdates(xp: number): Record<string, string> {
return {
xp: Math.max(0, Math.floor(xp)).toString(),
level: xpToLevel(xp).toString(),
};
}
+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 './useEmotionDev';
import { useEmotionDev } from './EmotionDevContext';
import { isLocalhostDev } from './index';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
+54 -2
View File
@@ -10,10 +10,26 @@
* - Is purely for visual testing/debugging
*/
import { useState, useCallback, type ReactNode } from 'react';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
import { isLocalhostDev } from './index';
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
// ─── 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);
// ─── Provider ─────────────────────────────────────────────────────────────────
@@ -52,4 +68,40 @@ 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';
}
+1 -2
View File
@@ -35,6 +35,5 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
// Emotion testing tools
export { EmotionDevProvider } from './EmotionDevContext';
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
+11 -1
View File
@@ -7,7 +7,7 @@
* IMPORTANT: This hook should only be used in development mode.
*/
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -24,6 +24,8 @@ interface UseBlobbiDevUpdateParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
interface DevUpdateResult {
@@ -48,9 +50,11 @@ 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> => {
@@ -165,6 +169,12 @@ 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
@@ -1,58 +0,0 @@
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';
}
-3
View File
@@ -1,5 +1,4 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { impactLight } from '@/lib/haptics';
import type { EggVisualBlobbi } from '../types/egg.types';
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
@@ -185,12 +184,10 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
// Tour interactive steps: forward click to tour controller
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
setIsTapWiggling(true);
impactLight();
onTourEggClick();
return;
}
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
impactLight();
setIsTapWiggling(true);
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
@@ -18,7 +18,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import { impactLight, impactMedium, impactHeavy, notificationSuccess } from '@/lib/haptics';
import { cn } from '@/lib/utils';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
@@ -159,7 +158,7 @@ export function BlobbiHatchingCeremony({
// Reveal phase state
const [blobbiVisible, setBlobbiVisible] = useState(false);
const [showFlash, setShowFlash] = useState(false);
const [, setShowRevealGlow] = useState(false);
const [showRevealGlow, setShowRevealGlow] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
// Dialog state
@@ -188,7 +187,7 @@ export function BlobbiHatchingCeremony({
// Baby companion (same visual data but stage=baby)
const babyCompanion = useMemo((): BlobbiCompanion | null => {
if (!eggCompanion) return null;
return { ...eggCompanion, stage: 'baby', state: 'evolving' };
return { ...eggCompanion, stage: 'baby', state: 'active' };
}, [eggCompanion]);
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
@@ -211,7 +210,7 @@ export function BlobbiHatchingCeremony({
ownerPubkey: user?.pubkey ?? '',
name: existingCompanion.name,
stage: 'egg',
state: (existingCompanion.state === 'incubating' ? 'incubating' : 'active') as 'incubating' | 'active',
state: 'active',
seed: existingCompanion.seed ?? '',
stats: {
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
@@ -387,8 +386,7 @@ export function BlobbiHatchingCeremony({
const babyTags = updateBlobbiTags(tags, {
stage: 'baby',
state: 'evolving',
state_started_at: nowStr,
state: 'active',
hunger: STAT_MAX.toString(),
happiness: STAT_MAX.toString(),
health: STAT_MAX.toString(),
@@ -414,19 +412,15 @@ export function BlobbiHatchingCeremony({
const handleEggClick = useCallback(() => {
if (phase === 'egg') {
triggerShake('animate-egg-onboard-shake-light');
impactLight();
setPhase('crack_1');
} else if (phase === 'crack_1') {
triggerShake('animate-egg-onboard-shake-medium');
impactMedium();
setPhase('crack_2');
} else if (phase === 'crack_2') {
triggerShake('animate-egg-onboard-shake-heavy');
impactHeavy();
setPhase('crack_3');
} else if (phase === 'crack_3') {
// Final click -> hatch!
notificationSuccess();
setPhase('hatching');
setShowFlash(true);
@@ -711,7 +705,7 @@ export function BlobbiHatchingCeremony({
{/* Wider golden halo */}
<div
className={cn(
'absolute rounded-full transition-opacity [transition-duration:2000ms]',
'absolute rounded-full transition-opacity duration-[2000ms]',
blobbiVisible ? 'opacity-100' : 'opacity-0',
)}
style={{
@@ -13,7 +13,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
interface BlobbiOnboardingFlowProps {
/** Current profile (null if doesn't exist) */
@@ -30,8 +30,6 @@ interface BlobbiOnboardingFlowProps {
setStoredSelectedD: (d: string) => void;
/** Called when onboarding is complete */
onComplete?: () => void;
/** 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.
@@ -47,7 +45,6 @@ export function BlobbiOnboardingFlow({
invalidateCompanion,
setStoredSelectedD,
onComplete,
existingCompanion,
adoptionOnly,
}: BlobbiOnboardingFlowProps) {
return (
@@ -59,7 +56,6 @@ export function BlobbiOnboardingFlow({
invalidateCompanion={invalidateCompanion}
setStoredSelectedD={setStoredSelectedD}
onComplete={onComplete}
existingCompanion={existingCompanion}
eggOnly={adoptionOnly}
/>
);
+5 -6
View File
@@ -35,8 +35,8 @@ export interface BlobbiEggPreview {
name: string;
/** Life stage - always 'egg' for previews */
stage: 'egg';
/** Activity state - new eggs start incubating; older eggs may be 'active' */
state: 'incubating' | 'active';
/** Activity state - always 'active' for new eggs */
state: 'active';
/** Visual traits derived from seed */
visualTraits: BlobbiVisualTraits;
/** Default stats for a new egg */
@@ -79,7 +79,7 @@ export function generateEggPreview(
seed,
name,
stage: 'egg',
state: 'incubating',
state: 'active',
visualTraits,
stats: { ...DEFAULT_EGG_STATS },
createdAt,
@@ -148,7 +148,6 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] {
['energy', preview.stats.energy.toString()],
['last_interaction', now],
['last_decay_at', now],
['state_started_at', now],
// Visual trait tags - ensures deterministic rendering
['base_color', visualTraits.baseColor],
['secondary_color', visualTraits.secondaryColor],
@@ -191,8 +190,8 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
startIncubation: undefined, // Deprecated field, no longer used
adultType: undefined, // Eggs don't have adult type
// Task-related fields
stateStartedAt: preview.createdAt,
// Task-related fields (not applicable to previews)
stateStartedAt: undefined,
tasks: [],
tasksCompleted: [],
@@ -1,299 +0,0 @@
/**
* BlobbiRoomHero Shared Blobbi visual display used in every room.
*
* Renders: stats crown (arced) + Blobbi visual + name.
* Does NOT clip or constrain fills available flex space.
* Top padding accounts for the floating room header overlay.
*/
import { useMemo } from 'react';
import {
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
Footprints, Loader2,
} from 'lucide-react';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BlobbiReactionState } from '@/blobbi/actions';
import type { BlobbiRoomId } from '../lib/room-config';
import { ROOM_META, DEFAULT_ROOM_ORDER, getRoomIndex } from '../lib/room-config';
import { cn } from '@/lib/utils';
// ─── Stat colour maps ─────────────────────────────────────────────────────────
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
hunger: 'orange',
happiness: 'yellow',
health: 'green',
hygiene: 'blue',
energy: 'violet',
};
const STAT_COLORS: Record<string, string> = {
orange: 'text-orange-500', yellow: 'text-yellow-500', green: 'text-green-500',
blue: 'text-blue-500', violet: 'text-violet-500',
};
const STAT_BG_COLORS: Record<string, string> = {
orange: 'bg-orange-500/10', yellow: 'bg-yellow-500/10', green: 'bg-green-500/10',
blue: 'bg-blue-500/10', violet: 'bg-violet-500/10',
};
const STAT_RING_HEX: Record<string, string> = {
orange: '#f97316', yellow: '#eab308', green: '#22c55e',
blue: '#3b82f6', violet: '#8b5cf6',
};
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
hunger: Utensils, happiness: Gamepad2, health: Heart, hygiene: Droplets, energy: Zap,
};
// ─── Props ────────────────────────────────────────────────────────────────────
export interface BlobbiRoomHeroProps {
companion: BlobbiCompanion;
currentStats: {
hunger: number;
happiness: number;
health: number;
hygiene: number;
energy: number;
};
isSleeping: boolean;
isEgg: boolean;
statusRecipe: BlobbiVisualRecipe | undefined;
statusRecipeLabel: string | undefined;
effectiveEmotion: BlobbiEmotion;
hasDevOverride: boolean;
blobbiReaction: BlobbiReactionState;
isActiveFloatingCompanion: boolean;
isUpdatingCompanion: boolean;
handleSetAsCompanion: () => Promise<void>;
heroRef: React.RefObject<HTMLDivElement | null>;
heroWidth: number;
/** Current room (for indicator below name) */
roomId: BlobbiRoomId;
/** Room order for dot indicators */
roomOrder?: BlobbiRoomId[];
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function BlobbiRoomHero({
companion,
currentStats,
isSleeping,
isEgg,
statusRecipe,
statusRecipeLabel,
effectiveEmotion,
hasDevOverride,
blobbiReaction,
isActiveFloatingCompanion,
isUpdatingCompanion,
handleSetAsCompanion,
heroRef,
heroWidth,
roomId,
roomOrder = DEFAULT_ROOM_ORDER,
className,
}: BlobbiRoomHeroProps) {
const roomMeta = ROOM_META[roomId];
const roomIndex = getRoomIndex(roomId, roomOrder);
if (isActiveFloatingCompanion) {
return (
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
<Footprints className="size-12 text-muted-foreground/30" />
<p className="text-muted-foreground text-sm">
{companion.name} is out exploring right now.
</p>
<button
onClick={handleSetAsCompanion}
disabled={isUpdatingCompanion}
className={cn(
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
isUpdatingCompanion && 'opacity-50 pointer-events-none',
)}
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
>
{isUpdatingCompanion ? <Loader2 className="size-4 animate-spin" /> : <Footprints className="size-4" />}
<span>Bring {companion.name} home</span>
</button>
</div>
);
}
return (
<div
ref={heroRef}
className={cn(
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
className,
)}
>
<div className="relative flex flex-col items-center">
<StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />
<div
className="relative transition-all duration-500"
style={!isSleeping ? {
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
} : undefined}
>
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
<BlobbiStageVisual
companion={companion}
size="lg"
animated={!isSleeping}
reaction={blobbiReaction}
recipe={hasDevOverride ? undefined : statusRecipe}
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
emotion={effectiveEmotion}
className={isEgg
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
}
/>
</div>
{!isEgg && (
<h2
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
style={{ color: companion.visualTraits.baseColor }}
>
{companion.name}
</h2>
)}
{/* Room indicator */}
<div className="flex flex-col items-center mt-2">
<div className="flex items-center gap-1.5">
<roomMeta.icon className="size-3.5 sm:size-4 text-foreground/50" />
<span className="text-xs sm:text-sm font-semibold text-foreground/60">{roomMeta.label}</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{roomOrder.map((id, i) => (
<div
key={id}
className={cn(
'rounded-full transition-all duration-300',
i === roomIndex ? 'w-4 h-1 bg-primary' : 'w-1 h-1 bg-muted-foreground/20',
)}
/>
))}
</div>
</div>
</div>
</div>
);
}
// ─── Stats Crown ──────────────────────────────────────────────────────────────
function StatsCrown({
companion,
currentStats,
heroWidth,
}: {
companion: BlobbiCompanion;
currentStats: BlobbiRoomHeroProps['currentStats'];
heroWidth: number;
}) {
const allStats = useMemo(() =>
getVisibleStats(companion.stage).map(stat => ({
stat,
value: currentStats[stat] ?? 100,
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
color: STAT_COLOR_MAP[stat],
})),
[companion.stage, currentStats]);
if (allStats.length === 0) return null;
const count = allStats.length;
const isSmall = heroWidth < 400;
const arcSpread = isSmall
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
const arcHalf = arcSpread / 2;
const angles = count === 1
? [0]
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
return (
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
{allStats.map((s, i) => {
const angleDeg = angles[i];
const angleRad = (angleDeg * Math.PI) / 180;
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
const x = Math.sin(angleRad) * radius;
const y = Math.cos(angleRad) * radius - radius;
return (
<div
key={s.stat}
className="absolute transition-all duration-500"
style={{
transform: 'translate(-50%, 0)',
left: `calc(50% + ${x.toFixed(1)}px)`,
bottom: `${y.toFixed(1)}px`,
}}
>
<StatIndicator stat={s.stat} value={s.value} color={s.color} status={s.status} />
</div>
);
})}
</div>
);
}
// ─── Stat Indicator ───────────────────────────────────────────────────────────
function StatIndicator({
stat,
value,
color,
status = 'normal',
}: {
stat: string;
value: number | undefined;
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
status?: 'normal' | 'warning' | 'critical';
}) {
const displayValue = value ?? 0;
const isLow = status === 'warning' || status === 'critical';
const ringHex = STAT_RING_HEX[color];
const IconComponent = STAT_ICON_MAP[stat];
return (
<div className={cn(
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
STAT_BG_COLORS[color],
status === 'critical' && 'animate-pulse',
)}>
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
<circle
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
stroke={ringHex}
strokeDasharray={`${displayValue * 0.94} 100`}
className="transition-all duration-500"
/>
</svg>
<div className="relative">
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
{isLow && (
<AlertTriangle
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
strokeWidth={3}
/>
)}
</div>
</div>
);
}
@@ -1,194 +0,0 @@
/**
* BlobbiRoomShell Outer layout for room-based navigation.
*
* Manages: room navigation (arrows + dots), sleep overlay, poop state.
* Renders children in a flex column with the hero above and children below.
* The parent decides what bottom bar to render based on the active room.
*/
import { useState, useCallback, useMemo, useEffect, useRef as useReactRef, type CSSProperties } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { impactLight } from '@/lib/haptics';
import {
type BlobbiRoomId,
ROOM_META,
DEFAULT_ROOM_ORDER,
getNextRoom,
getPreviousRoom,
} from '../lib/room-config';
import {
generateInitialPoops,
removePoop,
type PoopInstance,
} from '../lib/poop-system';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PoopState {
poops: PoopInstance[];
shovelMode: boolean;
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
onRemovePoop: (poopId: string) => void;
}
interface BlobbiRoomShellProps {
/** Current active room */
roomId: BlobbiRoomId;
/** Called when user navigates to a different room */
onChangeRoom: (roomId: BlobbiRoomId) => void;
/** Whether the Blobbi is sleeping (darkens the room) */
isSleeping: boolean;
/** Hero element (BlobbiRoomHero) rendered in the flex-1 area */
hero: React.ReactNode;
/** Bottom bar content (per-room actions + carousel) */
children: React.ReactNode;
/** Optional content between hero and bottom bar (inline music/sing) */
middleSlot?: React.ReactNode;
/** Room order (defaults to DEFAULT_ROOM_ORDER) */
roomOrder?: BlobbiRoomId[];
/** Poop generation params */
hunger: number;
lastFeedTimestamp: number | undefined;
/** Expose poop state to children via render prop or context */
poopStateRef?: React.MutableRefObject<PoopState | null>;
/** Called when a poop is cleaned. Parent handles toast/XP persistence. */
onPoopCleaned?: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
/** Minimum horizontal swipe distance (px) to trigger room change */
const SWIPE_THRESHOLD = 50;
export function BlobbiRoomShell({
roomId,
onChangeRoom,
isSleeping,
hero,
children,
middleSlot,
roomOrder = DEFAULT_ROOM_ORDER,
hunger,
lastFeedTimestamp,
poopStateRef,
onPoopCleaned,
}: BlobbiRoomShellProps) {
const goLeft = useCallback(() => {
onChangeRoom(getPreviousRoom(roomId, roomOrder));
}, [roomId, roomOrder, onChangeRoom]);
const goRight = useCallback(() => {
onChangeRoom(getNextRoom(roomId, roomOrder));
}, [roomId, roomOrder, onChangeRoom]);
const leftDest = ROOM_META[getPreviousRoom(roomId, roomOrder)];
const rightDest = ROOM_META[getNextRoom(roomId, roomOrder)];
// ─── Touch swipe ───
const touchStartX = useReactRef<number | null>(null);
const onTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
}, [touchStartX]);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
impactLight();
if (dx > 0) goLeft();
else goRight();
}, [touchStartX, goLeft, goRight]);
// ─── Poop system (ephemeral) ───
const [poops, setPoops] = useState<PoopInstance[]>([]);
const [shovelMode, setShovelMode] = useState(false);
useEffect(() => {
setPoops(generateInitialPoops(hunger, lastFeedTimestamp));
// Only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onRemovePoop = useCallback((poopId: string) => {
setPoops(prev => {
const { remaining } = removePoop(prev, poopId);
if (remaining.length < prev.length) {
onPoopCleaned?.();
}
if (remaining.length === 0) setShovelMode(false);
return remaining;
});
}, [onPoopCleaned]);
const poopState: PoopState = useMemo(() => ({
poops, shovelMode, setShovelMode, onRemovePoop,
}), [poops, shovelMode, onRemovePoop]);
if (poopStateRef) poopStateRef.current = poopState;
return (
<div
className="flex flex-col flex-1 min-h-0 relative"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Room content */}
<div className="flex-1 min-h-0 flex flex-col relative">
{hero}
{middleSlot}
{children}
</div>
{/* Sleep overlay */}
{isSleeping && (
<div
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
/>
)}
{/* Navigation arrows */}
<button
onClick={goLeft}
className={cn(
'group absolute left-1 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center',
'size-10 sm:size-12 rounded-full',
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
)}
aria-label={`Go to ${leftDest.label}`}
>
<ChevronLeft
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
/>
</button>
<button
onClick={goRight}
className={cn(
'group absolute right-1 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center',
'size-10 sm:size-12 rounded-full',
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
'cursor-pointer select-none',
)}
aria-label={`Go to ${rightDest.label}`}
>
<ChevronRight
className="size-7 sm:size-8 shrink-0"
strokeWidth={4}
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
/>
</button>
</div>
);
}
@@ -1,144 +0,0 @@
/**
* ItemCarousel Single-focus carousel for room items.
*
* Fixed-size slots prevent layout reflow on item switch.
* Mobile: focused item only. Desktop: prev/next previews.
*/
import { useState, useCallback, useEffect } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CarouselEntry {
id: string;
icon: React.ReactNode;
label: string;
meta?: string;
}
interface ItemCarouselProps {
items: CarouselEntry[];
onUse: (id: string) => void;
activeItemId?: string | null;
disabled?: boolean;
onFocusChange?: (entry: CarouselEntry) => void;
className?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ItemCarousel({
items,
onUse,
activeItemId,
disabled,
onFocusChange,
className,
}: ItemCarouselProps) {
const [index, setIndex] = useState(0);
const count = items.length;
// Reset index when items change to avoid out-of-bounds access
useEffect(() => {
setIndex(0);
}, [items]);
const prev = useCallback(() => {
setIndex(i => {
const n = (i - 1 + count) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
const next = useCallback(() => {
setIndex(i => {
const n = (i + 1) % count;
onFocusChange?.(items[n]);
return n;
});
}, [count, items, onFocusChange]);
if (count === 0) {
return (
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
</div>
);
}
const current = items[index];
const prevItem = items[(index - 1 + count) % count];
const nextItem = items[(index + 1) % count];
const isThisActive = activeItemId === current.id;
const showPreviews = count >= 3;
return (
<div className={cn('flex items-center justify-center', className)}>
<button
onClick={prev}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Previous item"
>
<ChevronLeft className="size-4" />
</button>
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{prevItem.icon}</span>
</div>
</div>
)}
<button
onClick={() => onUse(current.id)}
disabled={disabled}
className={cn(
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
'transition-colors duration-200',
'hover:bg-accent/20 active:scale-95',
isThisActive && 'bg-accent/40',
disabled && !isThisActive && 'opacity-50 pointer-events-none',
)}
>
<span className="text-4xl sm:text-5xl leading-none">{current.icon}</span>
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
{current.label}
</span>
{isThisActive && <Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />}
</button>
{showPreviews && (
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
<div className="opacity-20 scale-[0.6]">
<span className="text-2xl leading-none block">{nextItem.icon}</span>
</div>
</div>
)}
<button
onClick={next}
disabled={disabled}
className={cn(
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
'transition-all duration-200 active:scale-90',
disabled && 'opacity-30 pointer-events-none',
)}
aria-label="Next item"
>
<ChevronRight className="size-4" />
</button>
</div>
);
}
@@ -1,58 +0,0 @@
/**
* RoomActionButton Unified circular action button for room bottom bars.
*
* Responsive: size-14/size-20 circle, size-7/size-9 icons.
*/
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface RoomActionButtonProps {
icon: React.ReactNode;
label: string;
color: string;
glowHex: string;
onClick: () => void;
disabled?: boolean;
loading?: boolean;
badge?: React.ReactNode;
className?: string;
}
export function RoomActionButton({
icon,
label,
color,
glowHex,
onClick,
disabled,
loading,
badge,
className,
}: RoomActionButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
'hover:-translate-y-1 hover:scale-110 active:scale-95',
disabled && 'opacity-50 pointer-events-none',
className,
)}
>
<div className="relative">
<div
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
style={{
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
}}
>
{loading ? <Loader2 className="size-7 sm:size-9 animate-spin" /> : icon}
</div>
{badge && <div className="absolute -top-0.5 -right-0.5">{badge}</div>}
</div>
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
</button>
);
}

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