Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac1e82b52d | |||
| 437b8de652 | |||
| adadb6ed53 | |||
| f7c90a4a23 | |||
| 82632bb76c | |||
| 3a70d34e6d | |||
| 221d3f4aff | |||
| 6a1a462ab0 | |||
| 5ee8bc1cc0 | |||
| 76d53859cf | |||
| e482afbd3f | |||
| 11ff27efe2 | |||
| 8f6f678132 | |||
| f25139103c | |||
| 0028b506e7 | |||
| 926c27d51c | |||
| c4454ee2a1 | |||
| e56737f776 | |||
| feb6c1a9f6 | |||
| 6f8d225597 | |||
| 9ecd99a6a1 | |||
| 287097627d | |||
| 3ee491a63b | |||
| 7944f73da3 | |||
| 17c1936817 | |||
| c570f4689d | |||
| 064ab1e101 | |||
| 9c0d49b904 | |||
| 69634e7c05 | |||
| db48ce7c40 | |||
| 36c6e537a7 | |||
| cbc3df0bef | |||
| 2ecd557430 | |||
| 594e7ea8fa | |||
| 0a5e72efd0 | |||
| 0f1021e0d3 | |||
| be65c659b2 | |||
| b64aa4b24a | |||
| f63d8943d8 | |||
| e6efdc3539 | |||
| 517a72cce7 | |||
| ebe0cfdf03 | |||
| a501337fd3 | |||
| e3916b3bc1 | |||
| de22e921d4 | |||
| 3a512f04e2 | |||
| 6fc68766c9 | |||
| bfd1daf7ba | |||
| ae81c13cc1 | |||
| 41358d27ce | |||
| ac8bffba23 | |||
| 748365de40 | |||
| 361f8b9506 | |||
| c1ec7a25ed | |||
| 272586d033 | |||
| c77c098843 | |||
| ea7afa94f7 | |||
| 0c29506402 | |||
| b0609e7877 | |||
| 946be28b81 | |||
| 89250c7472 | |||
| cfc7a0d31c | |||
| 21003e3aed | |||
| 93e8a6290f | |||
| 47831ffa64 | |||
| 1533420320 | |||
| e3ef542875 | |||
| 3bf55990c0 | |||
| 283b31813c | |||
| 6e1197a067 | |||
| b7d1fbf860 | |||
| 8fde660075 | |||
| 50c7d67928 | |||
| e355c43925 | |||
| 696204870d | |||
| 0a7e01d17c | |||
| dd87bc96ec | |||
| a12d5db560 | |||
| 614634789c | |||
| 29696fa3d3 | |||
| ffc31e8e8f | |||
| 720a7e91fe | |||
| 05096e2cd9 | |||
| 05667460eb | |||
| b10dae7655 | |||
| c799b9efd6 | |||
| fe4834e157 | |||
| 5d972249a4 | |||
| f607a01577 | |||
| 1e232e6a9e |
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: lockdown-mode
|
||||
description: Apple Lockdown Mode restrictions and their impact on web APIs inside WKWebView/Safari/WebView. Reference when debugging or building features for lockdown-enabled devices.
|
||||
---
|
||||
|
||||
# Apple Lockdown Mode
|
||||
|
||||
Apple's Lockdown Mode is an opt-in security hardening profile that disables or restricts many web platform APIs inside Safari and WKWebView. Since this app ships inside a Capacitor WKWebView shell, **every restriction that applies to Safari also applies to our app**.
|
||||
|
||||
## Platform Availability
|
||||
|
||||
Lockdown Mode is available on:
|
||||
|
||||
- **iOS 16** or later (iPhone)
|
||||
- **iPadOS 16** or later (iPad)
|
||||
- **watchOS 10** or later (Apple Watch)
|
||||
- **macOS Ventura** or later (Mac)
|
||||
|
||||
Additional protections are available starting in iOS 17, iPadOS 17, watchOS 10, and macOS Sonoma.
|
||||
|
||||
For full details, see [About Lockdown Mode](https://support.apple.com/en-us/105120) on Apple Support.
|
||||
|
||||
## Testing Baseline
|
||||
|
||||
This document is based on testing against **iOS 18.7 / Safari 26.4** on an iPhone with Lockdown Mode enabled (April 2026). The web API restrictions documented below apply to Safari and WKWebView across all supported platforms (iOS, iPadOS, and macOS).
|
||||
|
||||
## Blocked APIs
|
||||
|
||||
These APIs are **completely unavailable** (return `undefined`, `null`, or throw) when Lockdown Mode is active.
|
||||
|
||||
| API | Impact | Notes |
|
||||
|-----|--------|-------|
|
||||
| **IndexedDB** | Critical | `indexedDB` global is missing entirely. Any library that relies on IndexedDB for storage will fail (Dexie, idb, localForage with IndexedDB driver, etc.). |
|
||||
| **Service Workers** | High | `navigator.serviceWorker` is absent. No offline caching, no background sync, no push notifications via SW. |
|
||||
| **Cache API** | High | `caches` global is absent. Often used alongside Service Workers for offline strategies. |
|
||||
| **WebAssembly** | High | `WebAssembly` global is `undefined`. Libraries compiled to WASM (e.g. libsodium-wrappers, secp256k1-wasm, SQLite WASM) will not load. |
|
||||
| **Web Locks** | High | `navigator.locks` is absent. Cross-tab coordination patterns that depend on this will break silently. |
|
||||
| **WebRTC** | High | `RTCPeerConnection` is absent. No peer-to-peer audio/video/data channels. |
|
||||
| **WebGL / WebGL2** | Medium | All canvas `getContext('webgl'*)` calls return `null`. GPU-accelerated rendering, maps (Mapbox GL, deck.gl), and 3D are broken. |
|
||||
| **FileReader** | Medium | `FileReader` constructor is absent. Cannot read `Blob`/`File` objects client-side (e.g. image preview before upload). Use the `File` constructor + `URL.createObjectURL()` as a workaround for previews. |
|
||||
| **SharedArrayBuffer** | Medium | `SharedArrayBuffer` is `undefined`. May also require COOP/COEP headers on non-lockdown browsers, so this is often already unavailable. |
|
||||
| **Speech Synthesis** | Low | `window.speechSynthesis` is absent. Text-to-speech features won't work. |
|
||||
| **Notifications API** | Low | `Notification` is absent. Web push permission prompts won't appear. (Capacitor local notifications via the native plugin are unaffected.) |
|
||||
| **WebCodecs** | Low | `VideoDecoder` / `VideoEncoder` are absent (`AudioDecoder` remains). Low-level media processing is unavailable. |
|
||||
| **Gamepad API** | Low | `navigator.getGamepads` is absent. |
|
||||
| **OPFS** | Medium | `navigator.storage.getDirectory` method does not exist. The `navigator.storage` object is present but the Origin Private File System API is stripped. SQLite-over-OPFS and any other OPFS-based storage will fail. |
|
||||
| **Web Share API** | Low | `navigator.share` is absent. Use Capacitor's `@capacitor/share` plugin instead -- the native share sheet still works. |
|
||||
|
||||
## Available APIs
|
||||
|
||||
These APIs **still work** under Lockdown Mode and can be relied on.
|
||||
|
||||
| API | Notes |
|
||||
|-----|-------|
|
||||
| **File constructor** | `new File(...)` works. You can create File/Blob objects. |
|
||||
| **FontFace API** | Dynamic font loading via `new FontFace()` succeeds. Remote font fetches may fail with a network error (data URIs rejected). |
|
||||
| **JIT compilation** | JavaScript JIT appears active (~110ms for 1M iterations). Performance is not interpreter-level degraded. |
|
||||
| **PDF viewer** | `navigator.pdfViewerEnabled` is `true`. Inline `<embed type="application/pdf">` works. |
|
||||
| **Cookies** | `navigator.cookieEnabled` is `true`. |
|
||||
| **Credential Management** | `navigator.credentials` is available. |
|
||||
| **localStorage / sessionStorage** | Standard Web Storage APIs remain functional. |
|
||||
|
||||
## Implications for This App
|
||||
|
||||
### Storage
|
||||
|
||||
- **localStorage works** -- our primary client-side storage (app config, relay lists, etc.) is unaffected.
|
||||
- **IndexedDB is gone** -- if any dependency silently uses IndexedDB (e.g. some Nostr caching layers, TanStack Query persisters), it will fail. Ensure all storage paths fall back to localStorage or in-memory.
|
||||
- **OPFS is gone** -- `navigator.storage.getDirectory` is stripped (the method doesn't exist, though the `navigator.storage` object itself remains). SQLite-over-OPFS (e.g. wa-sqlite, sql.js with OPFS backend) and any other OPFS-based persistence will not work.
|
||||
|
||||
### Cryptography
|
||||
|
||||
- **WebAssembly is blocked** -- any WASM-based crypto libraries (secp256k1 compiled to WASM, libsodium WASM builds) will not load. Use pure-JS implementations (e.g. `@noble/secp256k1`, `@noble/hashes`) which are already what nostr-tools uses.
|
||||
- **WebCrypto (`crypto.subtle`)** -- not listed as blocked in testing. The SubtleCrypto API should still be available for NIP-44 encryption via the standard Web Crypto path.
|
||||
|
||||
### Media & Rendering
|
||||
|
||||
- **WebGL is gone** -- map libraries that require WebGL (Mapbox GL JS, Google Maps WebGL renderer) will show blank canvases. Use raster tile alternatives or static map images.
|
||||
- **FileReader is gone** -- image/file preview workflows that use `FileReader.readAsDataURL()` need a workaround. Use `URL.createObjectURL(file)` directly for `<img src>` previews instead.
|
||||
|
||||
### Communication
|
||||
|
||||
- **WebRTC is gone** -- any peer-to-peer features (voice/video calls, WebRTC data channels) are completely unavailable.
|
||||
- **Fetch / XMLHttpRequest** -- standard network requests appear unaffected. Relay WebSocket connections should work normally.
|
||||
|
||||
### Native Plugin Workarounds
|
||||
|
||||
Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView restrictions entirely:
|
||||
|
||||
| Blocked Web API | Capacitor Alternative |
|
||||
|---|---|
|
||||
| Web Share | `@capacitor/share` (already installed) |
|
||||
| Notifications | `@capacitor/local-notifications` (already installed) |
|
||||
| File downloads | `@capacitor/filesystem` + share (already implemented in `downloadFile.ts`) |
|
||||
|
||||
### Detection
|
||||
|
||||
The report used a scoring heuristic (8/12 key APIs blocked = 70%) to detect Lockdown Mode. There is no official API to query Lockdown Mode status. Detection relies on probing for the absence of multiple APIs that are specifically disabled by Lockdown Mode but normally present in Safari.
|
||||
|
||||
## Raw Diagnostic Report
|
||||
|
||||
For exact error messages, navigator properties, weight scores, and per-API diagnostic output, see [ios-report.txt](ios-report.txt).
|
||||
|
||||
## Guidance for Feature Decisions
|
||||
|
||||
When building new features, consider:
|
||||
|
||||
1. **Always provide pure-JS fallbacks** for any crypto or data-processing library that might ship a WASM build.
|
||||
2. **Never depend on IndexedDB or OPFS** as the sole storage mechanism. Both are completely stripped. Always fall back to localStorage or in-memory stores.
|
||||
3. **Avoid WebGL-dependent UI** for core functionality. Use it as a progressive enhancement with a CSS/Canvas 2D fallback.
|
||||
4. **Use Capacitor plugins** for sharing, notifications, and file operations rather than web APIs -- they work on all native platforms regardless of Lockdown Mode.
|
||||
5. **Test on a Lockdown Mode device** when shipping features that touch storage, crypto, or media APIs.
|
||||
@@ -0,0 +1,229 @@
|
||||
============================================================
|
||||
LOCKDOWN MODE DETECTOR REPORT
|
||||
2026-04-06T23:40:58.170Z
|
||||
============================================================
|
||||
|
||||
VERDICT: Lockdown Mode Likely Active
|
||||
8 of 12 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
|
||||
Score: 70% (8/12 key APIs blocked)
|
||||
|
||||
============================================================
|
||||
API TEST RESULTS (detailed)
|
||||
============================================================
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] IndexedDB (weight: 3)
|
||||
Client-side structured storage
|
||||
Result: Can't find variable: indexedDB
|
||||
Diagnostics:
|
||||
uncaught: Can't find variable: indexedDB
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebAssembly (weight: 2)
|
||||
Binary instruction execution
|
||||
Result: WebAssembly is undefined
|
||||
Diagnostics:
|
||||
typeof WebAssembly: undefined
|
||||
WebAssembly global does not exist
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Web Locks API (weight: 3)
|
||||
Cross-tab resource coordination
|
||||
Result: navigator.locks is undefined
|
||||
Diagnostics:
|
||||
typeof navigator.locks: undefined
|
||||
'locks' in navigator: false
|
||||
navigator.locks is falsy
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Speech Synthesis (weight: 3)
|
||||
Web Speech API (text-to-speech)
|
||||
Result: window.speechSynthesis is undefined
|
||||
Diagnostics:
|
||||
typeof window.speechSynthesis: undefined
|
||||
'speechSynthesis' in window: false
|
||||
typeof SpeechSynthesisUtterance: undefined
|
||||
speechSynthesis is falsy
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] FileReader API (weight: 2)
|
||||
Local file reading interface
|
||||
Result: FileReader is undefined
|
||||
Diagnostics:
|
||||
typeof FileReader: undefined
|
||||
FileReader constructor does not exist
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] File Constructor (weight: 2)
|
||||
File object creation
|
||||
Result: File created: name=test.txt size=4
|
||||
Diagnostics:
|
||||
typeof File: function
|
||||
calling new File(['test'], 'test.txt', {type:'text/plain'})...
|
||||
succeeded
|
||||
f.name: test.txt
|
||||
f.size: 4
|
||||
f.type: text/plain
|
||||
f instanceof Blob: true
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebGL (weight: 2)
|
||||
GPU-accelerated graphics
|
||||
Result: all WebGL contexts returned null
|
||||
Diagnostics:
|
||||
getContext('webgl2'): null
|
||||
getContext('webgl'): null
|
||||
getContext('experimental-webgl'): null
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebGL2 (weight: 1)
|
||||
Advanced GPU graphics context
|
||||
Result: getContext('webgl2') returned null
|
||||
Diagnostics:
|
||||
getContext('webgl2'): null
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Service Worker (weight: 1)
|
||||
Background script registration
|
||||
Result: navigator.serviceWorker not present
|
||||
Diagnostics:
|
||||
'serviceWorker' in navigator: false
|
||||
typeof navigator.serviceWorker: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Web Share API (weight: 0)
|
||||
Native sharing interface
|
||||
Result: navigator.share is undefined
|
||||
Diagnostics:
|
||||
typeof navigator.share: undefined
|
||||
typeof navigator.canShare: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Gamepad API (weight: 1)
|
||||
Game controller input
|
||||
Result: navigator.getGamepads not present
|
||||
Diagnostics:
|
||||
'getGamepads' in navigator: false
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebRTC (weight: 2)
|
||||
Real-time peer communication
|
||||
Result: RTCPeerConnection is undefined
|
||||
Diagnostics:
|
||||
typeof RTCPeerConnection: undefined
|
||||
typeof webkitRTCPeerConnection: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] FontFace API (weight: 1)
|
||||
Dynamic font loading
|
||||
Result: status: loaded
|
||||
Diagnostics:
|
||||
typeof FontFace: function
|
||||
new FontFace() succeeded
|
||||
ff.status: unloaded
|
||||
ff.family: test
|
||||
ff.status after load: loaded
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] Remote Fonts (weight: 2)
|
||||
Loading fonts from network via data URI
|
||||
Result: API works, load rejected: A network error occurred.
|
||||
Diagnostics:
|
||||
FontFace created with data URI
|
||||
ff.status before load: unloaded
|
||||
caught: DOMException: A network error occurred.
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] JIT Compilation (weight: 2)
|
||||
JavaScript JIT optimization heuristic
|
||||
Result: 99.0ms for 1M iterations (JIT likely)
|
||||
Diagnostics:
|
||||
running 1,000,000 iterations of Math.sqrt*Math.sin...
|
||||
elapsed: 99.00ms
|
||||
sum (to prevent dead-code elimination): -681.7597
|
||||
threshold: <150ms suggests JIT active
|
||||
verdict: likely JIT
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Notifications API (weight: 1)
|
||||
Push notification permission
|
||||
Result: Notification not in window
|
||||
Diagnostics:
|
||||
'Notification' in window: false
|
||||
typeof Notification: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] WebCodecs (weight: 1)
|
||||
Low-level VideoDecoder API
|
||||
Result: VideoDecoder is undefined
|
||||
Diagnostics:
|
||||
typeof VideoDecoder: undefined
|
||||
typeof VideoEncoder: undefined
|
||||
typeof AudioDecoder: function
|
||||
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] PDF Embed (weight: 2)
|
||||
Inline PDF rendering via embed/pdfViewerEnabled
|
||||
Result: pdfViewerEnabled is true
|
||||
Diagnostics:
|
||||
navigator.pdfViewerEnabled: true
|
||||
typeof navigator.pdfViewerEnabled: boolean
|
||||
created and appended <embed type=application/pdf>
|
||||
navigator.mimeTypes['application/pdf']: [object MimeType]
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] SharedArrayBuffer (weight: 1)
|
||||
Shared memory between workers
|
||||
Result: SharedArrayBuffer is undefined
|
||||
Diagnostics:
|
||||
typeof SharedArrayBuffer: undefined
|
||||
requires COOP/COEP headers to be present; may not indicate Lockdown Mode specifically
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] Cache API (weight: 1)
|
||||
Programmatic HTTP cache (CacheStorage)
|
||||
Result: caches not in window
|
||||
Diagnostics:
|
||||
'caches' in window: false
|
||||
typeof caches: undefined
|
||||
|
||||
------------------------------------------------------------
|
||||
[BLOCKED] OPFS (weight: 2)
|
||||
Origin Private File System (navigator.storage.getDirectory)
|
||||
Result: navigator.storage.getDirectory is not a function
|
||||
Diagnostics:
|
||||
typeof navigator.storage: object
|
||||
typeof navigator.storage.getDirectory: undefined
|
||||
getDirectory method does not exist
|
||||
|
||||
============================================================
|
||||
NAVIGATOR INFO
|
||||
============================================================
|
||||
|
||||
userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1
|
||||
platform: iPhone
|
||||
vendor: Apple Computer, Inc.
|
||||
language: en-US
|
||||
languages: en-US
|
||||
cookieEnabled: true
|
||||
doNotTrack: null
|
||||
maxTouchPoints: 5
|
||||
hardwareConcurrency: 4
|
||||
deviceMemory: N/A
|
||||
pdfViewerEnabled: true
|
||||
webdriver: false
|
||||
connection: unavailable
|
||||
mediaDevices: unavailable
|
||||
storage: available
|
||||
serviceWorker: unavailable
|
||||
credentials: available
|
||||
bluetooth: unavailable
|
||||
gpu (WebGPU): unavailable
|
||||
screenWidth: 393
|
||||
screenHeight: 852
|
||||
devicePixelRatio: 3
|
||||
colorDepth: 24
|
||||
|
||||
============================================================
|
||||
END OF REPORT
|
||||
============================================================
|
||||
+3
-1
@@ -2,4 +2,6 @@ VITE_SENTRY_DSN="https://********************************@*****************.exam
|
||||
VITE_PLAUSIBLE_DOMAIN="example.tld"
|
||||
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
|
||||
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
+18
-1
@@ -57,6 +57,22 @@ deploy-nsite:
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
build-web:
|
||||
stage: build
|
||||
timeout: 10 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: never
|
||||
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||
script:
|
||||
- npm ci
|
||||
- npm run build
|
||||
- cp dist/index.html dist/404.html
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
image: eclipse-temurin:21-jdk
|
||||
@@ -129,8 +145,9 @@ build-apk:
|
||||
- npx vite build -l error
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Sync web assets to Capacitor Android project
|
||||
# Sync web assets to Capacitor Android project and register local plugins
|
||||
- npx cap sync android
|
||||
- node scripts/patch-cap-config.mjs
|
||||
|
||||
# Build signed release APK
|
||||
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
|
||||
|
||||
@@ -699,23 +699,47 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
const currentTags = prev?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
|
||||
await publishEvent({
|
||||
kind: 10003,
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
#### The `prev` Property on Event Templates
|
||||
|
||||
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
|
||||
|
||||
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
|
||||
- **Update (`prev` provided)**: `published_at` is preserved from the old event
|
||||
- **Old event lacks `published_at`**: nothing is fabricated
|
||||
- **Caller already set `published_at` in tags**: left alone
|
||||
|
||||
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
|
||||
|
||||
```typescript
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
// ...
|
||||
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
|
||||
```
|
||||
|
||||
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
|
||||
|
||||
### D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.5.2"
|
||||
versionName "2.6.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -11,9 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capacitor-secure-storage-plugin')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
@@ -14,6 +17,10 @@ import org.json.JSONArray;
|
||||
/**
|
||||
* Capacitor plugin that allows the JS layer to configure the native
|
||||
* notification polling service with the user's pubkey and relay URLs.
|
||||
*
|
||||
* Supports two notification styles:
|
||||
* - "push" (default): no foreground service, relies on push notifications
|
||||
* - "persistent": starts NotificationRelayService as a foreground service
|
||||
*/
|
||||
@CapacitorPlugin(name = "DittoNotification")
|
||||
public class DittoNotificationPlugin extends Plugin {
|
||||
@@ -24,6 +31,7 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
@PluginMethod
|
||||
public void configure(PluginCall call) {
|
||||
String userPubkey = call.getString("userPubkey");
|
||||
String notificationStyle = call.getString("notificationStyle", "push");
|
||||
String relayUrlsRaw = null;
|
||||
String enabledKindsRaw = null;
|
||||
String authorsRaw = null;
|
||||
@@ -60,7 +68,8 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
if (userPubkey != null && relayUrlsRaw != null) {
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString("userPubkey", userPubkey)
|
||||
.putString("relayUrls", relayUrlsRaw);
|
||||
.putString("relayUrls", relayUrlsRaw)
|
||||
.putString("notificationStyle", notificationStyle);
|
||||
if (enabledKindsRaw != null) {
|
||||
editor.putString("enabledKinds", enabledKindsRaw);
|
||||
}
|
||||
@@ -70,13 +79,46 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
editor.remove("authors");
|
||||
}
|
||||
editor.apply();
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
} else {
|
||||
// Clear config (user logged out)
|
||||
prefs.edit().clear().apply();
|
||||
Log.d(TAG, "Config cleared (user logged out)");
|
||||
}
|
||||
|
||||
// Start or stop the foreground service based on style
|
||||
manageService(notificationStyle, userPubkey != null && relayUrlsRaw != null);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the foreground service when style is "persistent" and config is valid.
|
||||
* Stop it otherwise.
|
||||
*/
|
||||
private void manageService(String style, boolean hasConfig) {
|
||||
Context ctx = getContext();
|
||||
Intent serviceIntent = new Intent(ctx, NotificationRelayService.class);
|
||||
|
||||
if ("persistent".equals(style) && hasConfig) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(serviceIntent);
|
||||
} else {
|
||||
ctx.startService(serviceIntent);
|
||||
}
|
||||
Log.d(TAG, "Started NotificationRelayService (persistent mode)");
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w(TAG, "Could not start foreground service: " + e.getMessage());
|
||||
} else {
|
||||
Log.w(TAG, "Failed to start service", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.stopService(serviceIntent);
|
||||
Log.d(TAG, "Stopped NotificationRelayService (push mode or no config)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
@@ -11,32 +13,36 @@ import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
private static final String PREFS_NAME = "ditto_notification_config";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register the native notification config plugin before super.onCreate
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(SandboxPlugin.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Start the persistent relay connection service.
|
||||
// On Android 12+ (API 31+) the system may throw
|
||||
// ForegroundServiceStartNotAllowedException if the foreground service
|
||||
// time limit for this type has already been exhausted. We catch it so
|
||||
// the app continues to run normally; the alarm inside the service will
|
||||
// retry at the next scheduled interval.
|
||||
try {
|
||||
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
|
||||
} else {
|
||||
throw e;
|
||||
// Only start the foreground service if the user has opted into
|
||||
// "persistent" notification style. Default is "push" (no service).
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
String style = prefs.getString("notificationStyle", "push");
|
||||
|
||||
if ("persistent".equals(style)) {
|
||||
try {
|
||||
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* 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 WebView 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.webView, params);
|
||||
|
||||
// Load the initial page.
|
||||
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.webView.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.webView.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.webView);
|
||||
}
|
||||
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;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
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.WHITE);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
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 this thread until JS responds (with a timeout).
|
||||
WebResourceResponse response = pending.awaitResponse(10000);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 thread until resolved.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private WebResourceResponse response;
|
||||
private final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
@@ -16,3 +19,6 @@ project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/sh
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
include ':capacitor-secure-storage-plugin'
|
||||
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
|
||||
|
||||
+7
-2
@@ -17,9 +17,14 @@ const config: CapacitorConfig = {
|
||||
},
|
||||
ios: {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'automatic',
|
||||
contentInset: 'never',
|
||||
scheme: 'Ditto'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
resizeOnFullScreen: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android"] },
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -28,6 +30,8 @@
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -64,6 +68,8 @@
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
@@ -156,6 +162,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -303,7 +311,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.2;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +333,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.2;
|
||||
MARKETING_VERSION = 2.6.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
class DittoBridgeViewController: CAPBridgeViewController {
|
||||
override func capacitorDidLoad() {
|
||||
super.capacitorDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
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: "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 WebView on top of the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.webView)
|
||||
}
|
||||
|
||||
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.webView.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.webView.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 {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
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
|
||||
|
||||
self.webView = WKWebView(frame: frame, configuration: config)
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = .white
|
||||
self.webView.scrollView.bounces = false
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
|
||||
// Load the initial page via the custom scheme.
|
||||
let initialURL = URL(string: "\(self.customScheme)://app/index.html")!
|
||||
self.webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// 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: - 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"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ let package = Package(
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar"),
|
||||
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -26,9 +28,11 @@ let package = Package(
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"),
|
||||
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+118
-119
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.1",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
@@ -59,7 +60,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -95,6 +96,7 @@
|
||||
"@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",
|
||||
@@ -379,6 +381,15 @@
|
||||
"@capacitor/core": "^8.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
|
||||
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
|
||||
"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",
|
||||
@@ -2389,20 +2400,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"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==",
|
||||
"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": {
|
||||
@@ -2527,9 +2540,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
|
||||
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
|
||||
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
@@ -2556,9 +2569,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
|
||||
"integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -5391,9 +5404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5408,9 +5421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5425,9 +5438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5442,9 +5455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5459,9 +5472,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -5476,9 +5489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5493,9 +5506,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5510,9 +5523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -5527,9 +5540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -5544,9 +5557,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5561,9 +5574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5578,9 +5591,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5595,9 +5608,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -5605,16 +5618,18 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
"@emnapi/core": "1.9.1",
|
||||
"@emnapi/runtime": "1.9.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5629,9 +5644,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5699,7 +5714,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5713,7 +5727,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5727,7 +5740,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5741,7 +5753,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5755,7 +5766,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5769,7 +5779,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5783,7 +5792,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5797,7 +5805,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5811,7 +5818,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5825,7 +5831,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5839,7 +5844,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5853,7 +5857,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5867,7 +5870,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5881,7 +5883,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5895,7 +5896,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5909,7 +5909,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5923,7 +5922,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5937,7 +5935,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5951,7 +5948,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5965,7 +5961,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5979,7 +5974,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5993,7 +5987,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6007,7 +6000,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6021,7 +6013,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6035,7 +6026,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7670,6 +7660,15 @@
|
||||
],
|
||||
"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",
|
||||
@@ -12589,14 +12588,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"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==",
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
"@oxc-project/types": "=0.123.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -12605,27 +12604,27 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -14037,16 +14036,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
|
||||
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"rolldown": "1.0.0-rc.13",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -14064,7 +14063,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
@@ -14653,9 +14652,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15354,9 +15353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+5
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.5.2",
|
||||
"version": "2.6.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
|
||||
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
},
|
||||
@@ -17,6 +18,7 @@
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
@@ -65,7 +67,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -101,6 +103,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
|
||||
*
|
||||
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
|
||||
* only, so local plugins compiled directly into the app binary (like
|
||||
* SandboxPlugin) are not included. This script appends them after sync so
|
||||
* the Capacitor bridge eagerly registers them at startup.
|
||||
*
|
||||
* Usage: node scripts/patch-cap-config.mjs
|
||||
* Typically run after `npx cap sync`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** Local plugin class names to ensure are registered. */
|
||||
const LOCAL_PLUGINS = ['SandboxPlugin'];
|
||||
|
||||
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
|
||||
|
||||
for (const platform of platforms) {
|
||||
const configPath = resolve(platform, 'capacitor.config.json');
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
} catch {
|
||||
// Platform may not exist or config not yet generated — skip.
|
||||
continue;
|
||||
}
|
||||
|
||||
const classList = new Set(config.packageClassList ?? []);
|
||||
let changed = false;
|
||||
|
||||
for (const plugin of LOCAL_PLUGINS) {
|
||||
if (!classList.has(plugin)) {
|
||||
classList.add(plugin);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
config.packageClassList = [...classList];
|
||||
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
|
||||
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -24,6 +24,7 @@ import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -149,6 +150,8 @@ const hardcodedConfig: AppConfig = {
|
||||
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
|
||||
savedFeeds: [],
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -198,7 +201,7 @@ export function App() {
|
||||
<SentryProvider>
|
||||
<PlausibleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey="nostr:login">
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
@@ -77,6 +77,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -151,6 +152,9 @@ export function AppRouter() {
|
||||
</Suspense>
|
||||
</BlobbiActionsProvider>
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -37,8 +34,8 @@ interface BlobbiActionInventoryModalProps {
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user confirms using item(s). Now accepts quantity. */
|
||||
onUseItem: (itemId: string, quantity: number) => void;
|
||||
/** Called when user taps Use on an item. Always uses once. */
|
||||
onUseItem: (itemId: string) => void;
|
||||
onOpenShop: () => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
@@ -49,24 +46,19 @@ export function BlobbiActionInventoryModal({
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
profile,
|
||||
profile: _profile,
|
||||
onUseItem,
|
||||
onOpenShop,
|
||||
onOpenShop: _onOpenShop,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
}: BlobbiActionInventoryModalProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
|
||||
// State for confirmation dialog
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// Filter inventory by action type, respecting egg-compatible effects
|
||||
// Get all available items for this action from the catalog (not inventory).
|
||||
// Items are abilities/tools — no ownership required.
|
||||
const availableItems = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
|
||||
}, [profile, action, companion.stage]);
|
||||
return filterInventoryByAction([], action, { stage: companion.stage });
|
||||
}, [action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
const canUse = canUseAction(companion, action);
|
||||
@@ -74,46 +66,9 @@ export function BlobbiActionInventoryModal({
|
||||
|
||||
const isEmpty = availableItems.length === 0;
|
||||
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
setQuantity(1);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
// Reset after starting use
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleCloseConfirmDialog = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShop = () => {
|
||||
onOpenChange(false);
|
||||
onOpenShop();
|
||||
};
|
||||
|
||||
// Quantity controls
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else {
|
||||
setQuantity(Math.min(value, maxQuantity));
|
||||
}
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -161,14 +116,10 @@ export function BlobbiActionInventoryModal({
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">{actionMeta.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-4">
|
||||
You don't have any items for this action. Visit the shop to get some!
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for this action at your Blobbi's current stage.
|
||||
</p>
|
||||
<Button onClick={handleOpenShop} className="gap-2">
|
||||
<ShoppingBag className="size-4" />
|
||||
Open Shop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -181,7 +132,7 @@ export function BlobbiActionInventoryModal({
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleSelectItem(item)}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
disabled={isUsingItem}
|
||||
/>
|
||||
@@ -190,24 +141,6 @@ export function BlobbiActionInventoryModal({
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Confirmation Dialog with Quantity Selector */}
|
||||
{selectedItem && (
|
||||
<BlobbiUseItemConfirmDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={handleCloseConfirmDialog}
|
||||
item={selectedItem}
|
||||
companion={companion}
|
||||
action={action}
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onQuantityChange={handleQuantityInput}
|
||||
onConfirm={handleConfirmUse}
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -238,15 +171,12 @@ function BlobbiInventoryUseRow({
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
// For eggs using medicine, show health preview
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
|
||||
};
|
||||
}
|
||||
if (isEgg && isClean) {
|
||||
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewCleanForEgg(
|
||||
@@ -255,7 +185,6 @@ function BlobbiInventoryUseRow({
|
||||
),
|
||||
};
|
||||
}
|
||||
// Normal stats preview
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
@@ -280,16 +209,12 @@ function BlobbiInventoryUseRow({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
x{item.quantity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown inline on desktop */}
|
||||
<div className="hidden sm:block">
|
||||
{hasChanges && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -308,7 +233,6 @@ function BlobbiInventoryUseRow({
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -350,7 +274,6 @@ function BlobbiInventoryUseRow({
|
||||
{/* Effect Preview - shown below on mobile */}
|
||||
{hasChanges && (
|
||||
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -369,7 +292,6 @@ function BlobbiInventoryUseRow({
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -393,222 +315,3 @@ function BlobbiInventoryUseRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
|
||||
|
||||
interface BlobbiUseItemConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onConfirm: () => void;
|
||||
isUsing: boolean;
|
||||
}
|
||||
|
||||
function BlobbiUseItemConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: BlobbiUseItemConfirmDialogProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Preview stat changes for the selected quantity
|
||||
const statPreview = useMemo(() => {
|
||||
if (!item.effect) return { normalChanges: [], eggChanges: [] };
|
||||
|
||||
if (isEgg && isMedicine) {
|
||||
// Calculate health change for N items
|
||||
const healthDelta = item.effect.health ?? 0;
|
||||
let currentHealth = companion.stats.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
|
||||
}
|
||||
const totalDelta = currentHealth - (companion.stats.health ?? 0);
|
||||
return {
|
||||
normalChanges: [],
|
||||
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isEgg && isClean) {
|
||||
// Calculate hygiene and happiness changes for N items
|
||||
const hygieneDelta = item.effect.hygiene ?? 0;
|
||||
const happinessDelta = item.effect.happiness ?? 0;
|
||||
let currentHygiene = companion.stats.hygiene ?? 0;
|
||||
let currentHappiness = companion.stats.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
|
||||
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
|
||||
}
|
||||
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
|
||||
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
|
||||
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
|
||||
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
|
||||
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
|
||||
return { normalChanges: [], eggChanges: changes };
|
||||
}
|
||||
|
||||
// Normal stats preview - simulate N applications
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
if (delta !== undefined) {
|
||||
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
const changes: Array<{ stat: string; delta: number }> = [];
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
if (delta !== 0) {
|
||||
changes.push({ stat, delta });
|
||||
}
|
||||
}
|
||||
return { normalChanges: changes, eggChanges: [] };
|
||||
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionMeta.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Item Preview */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onDecrease}
|
||||
disabled={quantity <= 1 || isUsing}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
value={quantity}
|
||||
onChange={onQuantityChange}
|
||||
disabled={isUsing}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onIncrease}
|
||||
disabled={quantity >= maxQuantity || isUsing}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{hasChanges && (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statPreview.normalChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
{statPreview.eggChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUsing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isUsing}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isUsing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Using...
|
||||
</>
|
||||
) : (
|
||||
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,13 +98,6 @@ export function InlineSingCard({
|
||||
cleanup: cleanupPlayback,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup all resources
|
||||
const cleanupAll = useCallback(() => {
|
||||
// Stop timer
|
||||
@@ -138,6 +131,13 @@ export function InlineSingCard({
|
||||
}
|
||||
}, [audioUrl, cleanupPlayback]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, [cleanupAll]);
|
||||
|
||||
// Reset recording
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanupAll();
|
||||
|
||||
@@ -82,22 +82,6 @@ export function SingModal({
|
||||
// Track the actual MIME type used by the recorder
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
@@ -142,6 +126,22 @@ export function SingModal({
|
||||
// Keep lyrics when re-recording so user can sing the same song
|
||||
}, [cleanup]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open, cleanup, resetRecording]);
|
||||
|
||||
// Handle getting random lyrics
|
||||
const handleRandomLyrics = useCallback(() => {
|
||||
const lyrics = getRandomLyrics();
|
||||
|
||||
@@ -168,7 +168,7 @@ export function useActiveTaskProcess(
|
||||
}, [processType, hatchTasks, evolveTasks]);
|
||||
|
||||
// Extract tasks and state from active result
|
||||
const tasks = activeResult?.tasks ?? [];
|
||||
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
|
||||
const isLoading = activeResult?.isLoading ?? false;
|
||||
const allCompleted = activeResult?.allCompleted ?? false;
|
||||
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -24,7 +25,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
@@ -34,8 +40,6 @@ export interface UseBlobbiCareActivityParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
export interface CareActivityResult {
|
||||
@@ -59,8 +63,8 @@ export interface CareActivityResult {
|
||||
export function useBlobbiCareActivity({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiCareActivityParams) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -78,12 +82,24 @@ export function useBlobbiCareActivity({
|
||||
throw new Error('No companion available');
|
||||
}
|
||||
|
||||
// Fetch fresh companion from relays (read-modify-write pattern)
|
||||
const freshEvents = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [companion.d],
|
||||
}]);
|
||||
const freshCompanion = freshEvents
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map(e => parseBlobbiEvent(e))
|
||||
.find(Boolean) ?? companion;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Calculate what the streak update should be
|
||||
// Calculate what the streak update should be using fresh data
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
freshCompanion.careStreak,
|
||||
freshCompanion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
@@ -96,29 +112,29 @@ export function useBlobbiCareActivity({
|
||||
};
|
||||
}
|
||||
|
||||
// Get the tag updates
|
||||
const streakUpdates = getStreakTagUpdates(companion, now);
|
||||
// Get the tag updates using fresh data
|
||||
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
|
||||
|
||||
if (!streakUpdates) {
|
||||
// Shouldn't happen if wasUpdated is true, but handle gracefully
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
newStreak: freshCompanion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Build updated tags
|
||||
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
|
||||
// Build updated tags from fresh data
|
||||
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
|
||||
|
||||
// Publish the updated event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content,
|
||||
content: freshCompanion.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update local cache
|
||||
// Update local cache (optimistic — no invalidation needed)
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// Update session tracker
|
||||
@@ -128,9 +144,9 @@ export function useBlobbiCareActivity({
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CareActivity] Streak updated:', {
|
||||
action: result.action,
|
||||
previousStreak: companion.careStreak,
|
||||
previousStreak: freshCompanion.careStreak,
|
||||
newStreak: result.newStreak,
|
||||
lastDay: companion.careStreakLastDay,
|
||||
lastDay: freshCompanion.careStreakLastDay,
|
||||
newDay: result.newLastDay,
|
||||
});
|
||||
}
|
||||
@@ -141,11 +157,6 @@ export function useBlobbiCareActivity({
|
||||
action: result.action,
|
||||
};
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.wasUpdated) {
|
||||
invalidateCompanion();
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[CareActivity] Failed to update streak:', error);
|
||||
},
|
||||
|
||||
@@ -69,15 +69,11 @@ export interface UseBlobbiDirectActionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration happened) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to execute a direct action on a Blobbi companion.
|
||||
* Direct actions (play_music, sing) don't consume inventory items.
|
||||
* Direct actions (play_music, sing) don't require selecting an item.
|
||||
* They directly affect happiness stat.
|
||||
*
|
||||
* This hook:
|
||||
@@ -92,8 +88,6 @@ export function useBlobbiDirectAction({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiDirectActionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -189,12 +183,6 @@ export function useBlobbiDirectAction({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
|
||||
@@ -66,10 +66,6 @@ export interface UseStartIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +108,6 @@ export function useStartIncubation({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
@@ -269,12 +263,6 @@ export function useStartIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -329,10 +317,6 @@ export interface UseStopIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,8 +347,6 @@ export function useStopIncubation({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -435,12 +417,6 @@ export function useStopIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -480,10 +456,6 @@ export interface UseStartEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,8 +483,6 @@ export function useStartEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -585,12 +555,6 @@ export function useStartEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -631,10 +595,6 @@ export interface UseStopEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -665,8 +625,6 @@ export function useStopEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -736,12 +694,6 @@ export function useStopEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -784,10 +736,6 @@ export interface UseSyncTaskCompletionsParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -827,8 +775,6 @@ export function useSyncTaskCompletions({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseSyncTaskCompletionsParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -923,11 +869,6 @@ export function useSyncTaskCompletions({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Published successfully:', tagsToAdd);
|
||||
|
||||
@@ -69,10 +69,6 @@ export interface UseBlobbiStageTransitionParams {
|
||||
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +109,6 @@ export function useBlobbiHatch({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -220,12 +214,6 @@ export function useBlobbiHatch({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'egg',
|
||||
@@ -268,8 +256,6 @@ export function useBlobbiEvolve({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -376,12 +362,6 @@ export function useBlobbiEvolve({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'baby',
|
||||
|
||||
@@ -6,19 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
clampStat,
|
||||
@@ -37,23 +33,19 @@ import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
/**
|
||||
* Request payload for using an inventory item
|
||||
* Request payload for using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemRequest {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
/** Number of items to use (defaults to 1) */
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of using an inventory item
|
||||
* Result of using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
itemName: string;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
|
||||
statsChanged: Record<string, number>;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
@@ -71,50 +63,44 @@ export interface UseBlobbiUseInventoryItemParams {
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration (use instead of profile.allTags) */
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration (use instead of profile.storage) */
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Update profile event in local cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to use an inventory item on a Blobbi companion.
|
||||
* Hook to use an item on a Blobbi companion.
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — no
|
||||
* inventory ownership or quantity is required.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion stage (eggs can't use items)
|
||||
* 2. Validates the item exists in storage
|
||||
* 3. Ensures canonical format before action
|
||||
* 4. Applies item effects to Blobbi stats
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
* 6. Decrements item from profile storage (kind 11125)
|
||||
* 7. Invalidates relevant queries
|
||||
* 1. Validates the companion and item compatibility
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay, then item effects to Blobbi stats
|
||||
* 4. Updates Blobbi state (kind 31124)
|
||||
*/
|
||||
export function useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
updateProfileEvent: _updateProfileEvent,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
|
||||
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
@@ -128,11 +114,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Validate quantity
|
||||
if (quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
@@ -145,15 +126,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -216,78 +188,25 @@ export function useBlobbiUseInventoryItem({
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
|
||||
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
|
||||
// won't give more than 100 health total.
|
||||
//
|
||||
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
|
||||
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
|
||||
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
|
||||
// - clean: count when hygiene or happiness INCREASES
|
||||
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
|
||||
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
|
||||
//
|
||||
// Use canonical companion stage for egg checks
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
const isEggCompanion = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
let effectiveItemCount = 0; // Number of items that produced intended effects
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
// Egg medicine handling:
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
// Medicine with health effect directly affects the egg's health stat
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
// Apply health effect N times in sequence with clamping at each step
|
||||
// Only count items that actually INCREASED health (positive effect only)
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHealth = currentHealth;
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
// Only count as effective if health increased (not just changed)
|
||||
if (healthDelta > 0 && currentHealth > prevHealth) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
// Track total actual change (may be less than healthDelta * quantity due to clamping)
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
|
||||
// Apply decayed values for other egg stats
|
||||
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
|
||||
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
// Egg clean/hygiene handling:
|
||||
// Hygiene items affect the egg's hygiene stat
|
||||
// Some hygiene items also give happiness (e.g., bubble bath)
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const hygieneDelta = shopItem.effect.hygiene ?? 0;
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
// Apply effects N times in sequence
|
||||
// Only count items that INCREASED hygiene or happiness (positive effects only)
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHygiene = currentHygiene;
|
||||
const prevHappiness = currentHappiness;
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
// Count as effective if hygiene OR happiness increased (positive effects only)
|
||||
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
|
||||
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
|
||||
if (hygieneIncreased || happinessIncreased) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
@@ -298,58 +217,12 @@ export function useBlobbiUseInventoryItem({
|
||||
statsChanged.happiness = totalHappinessChange;
|
||||
}
|
||||
|
||||
// Apply decayed health
|
||||
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
// Apply item effects N times in sequence ON TOP of decayed stats
|
||||
// Use action-aware effectiveness checking for XP calculation
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
const effect = shopItem.effect;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevStats = { ...currentStats };
|
||||
currentStats = applyItemEffects(currentStats, effect);
|
||||
|
||||
// Action-aware effectiveness check:
|
||||
// Only count INTENDED positive effects, not negative side effects
|
||||
let isEffective = false;
|
||||
|
||||
if (action === 'feed') {
|
||||
// Feed: count when hunger/energy/health/happiness INCREASE
|
||||
// Do NOT count hygiene decrease (that's a side effect)
|
||||
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
|
||||
} else if (action === 'clean') {
|
||||
// Clean: count when hygiene or happiness INCREASES
|
||||
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hygieneIncreased || happinessIncreased;
|
||||
} else if (action === 'medicine') {
|
||||
// Medicine: count when health/energy/happiness INCREASE
|
||||
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = healthIncreased || energyIncreased || happinessIncreased;
|
||||
} else if (action === 'play') {
|
||||
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
|
||||
// Playing naturally consumes energy, so energy decrease counts as valid
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
|
||||
isEffective = happinessIncreased || energyDecreased;
|
||||
}
|
||||
|
||||
if (isEffective) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
@@ -382,11 +255,8 @@ export function useBlobbiUseInventoryItem({
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (Based on effective item count) ───
|
||||
// Only grant XP for items that actually changed stats.
|
||||
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
|
||||
// This prevents XP farming by mass-using items after stats are already maxed.
|
||||
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
|
||||
// ─── Apply XP Gain ───
|
||||
const xpGained = calculateInventoryActionXP(action, 1);
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
@@ -406,46 +276,25 @@ export function useBlobbiUseInventoryItem({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
|
||||
// instead of profile.storage/profile.allTags to avoid restoring
|
||||
// stale/legacy values after migration
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
invalidateProfile();
|
||||
// Items are free to use — no storage decrement needed.
|
||||
// No query invalidation needed — the optimistic update above keeps the
|
||||
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
|
||||
// before every mutation (read-modify-write pattern).
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
quantity,
|
||||
effectiveItemCount, // How many items actually changed stats
|
||||
statsChanged,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, quantity, xpGained }) => {
|
||||
onSuccess: ({ itemName, action, xpGained }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
|
||||
description: `Used ${itemName} on your Blobbi. ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
|
||||
@@ -55,9 +55,6 @@ export {
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
buildHatchPhrase,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
// ─── Action Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Actions that consume inventory items
|
||||
* Item-based care actions (use a shop catalog item on the companion)
|
||||
*/
|
||||
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
|
||||
|
||||
/**
|
||||
* Non-inventory actions that don't consume items
|
||||
* These actions affect stats directly without using shop items.
|
||||
* Direct actions that don't use items.
|
||||
* These actions affect stats directly without selecting a shop item.
|
||||
*/
|
||||
export type DirectAction = 'play_music' | 'sing';
|
||||
|
||||
/**
|
||||
* All Blobbi actions (inventory + direct)
|
||||
* All Blobbi actions (item-based + direct)
|
||||
*/
|
||||
export type BlobbiAction = InventoryAction | DirectAction;
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for UI display (inventory actions)
|
||||
* Action metadata for UI display (item-based care actions)
|
||||
*/
|
||||
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
|
||||
feed: {
|
||||
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for direct actions (non-inventory)
|
||||
* Action metadata for direct actions (no item required)
|
||||
*/
|
||||
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
|
||||
play_music: {
|
||||
@@ -270,10 +270,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
|
||||
return effects.happiness !== undefined && effects.happiness !== 0;
|
||||
}
|
||||
|
||||
// ─── Inventory Helpers ────────────────────────────────────────────────────────
|
||||
// ─── Item Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved inventory item with shop metadata
|
||||
* Resolved catalog item with shop metadata
|
||||
*/
|
||||
export interface ResolvedInventoryItem {
|
||||
itemId: string;
|
||||
@@ -285,7 +285,7 @@ export interface ResolvedInventoryItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering inventory by action
|
||||
* Options for filtering catalog items by action
|
||||
*/
|
||||
export interface FilterInventoryOptions {
|
||||
/** Companion stage - used to filter items by egg-compatible effects */
|
||||
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inventory items by action type.
|
||||
* Returns resolved items with shop metadata.
|
||||
* Get all available items for an action type from the shop catalog.
|
||||
* Items are abilities/tools — no inventory ownership is required.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
storage: StorageItem[],
|
||||
_storage: StorageItem[],
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
const allowedType = ACTION_TO_ITEM_TYPE[action];
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
const isEgg = options.stage === 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
for (const storageItem of storage) {
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== allowedType) continue;
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
// Shell Repair Kit: only show for eggs in medicine modal
|
||||
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: shopItem.id,
|
||||
quantity: Infinity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
@@ -376,7 +374,7 @@ export function decrementStorageItem(
|
||||
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stages that can use general inventory items (food, toys, hygiene)
|
||||
* Stages that can use general items (food, toys, hygiene)
|
||||
*/
|
||||
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
|
||||
|
||||
@@ -409,14 +407,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
|
||||
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific inventory action.
|
||||
* Check if a companion can use a specific item action.
|
||||
*
|
||||
* Note: This function no longer hard-blocks egg actions at the domain layer.
|
||||
* UI visibility is handled separately by `isActionVisibleForStage()`.
|
||||
* The domain layer allows all actions - UI chooses what to show.
|
||||
*/
|
||||
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
|
||||
// All stages can technically use all inventory actions at the domain layer.
|
||||
// All stages can technically use all item actions at the domain layer.
|
||||
// UI filtering determines what actions are shown to users.
|
||||
return true;
|
||||
}
|
||||
@@ -442,7 +440,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use general inventory items (feed, play, clean).
|
||||
* Check if a companion can use general items (feed, play, clean).
|
||||
* Eggs cannot use food, toys, or hygiene items.
|
||||
* @deprecated Use canUseAction(companion, action) for action-specific checks
|
||||
*/
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* Design Philosophy:
|
||||
* - Different actions award different XP to reflect their complexity/value
|
||||
* - XP values are balanced to encourage variety in care activities
|
||||
* - Direct actions (sing, play_music) give moderate XP as they're free
|
||||
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
|
||||
* - Item actions (feed, play, clean, medicine) give varied XP per action type
|
||||
* - Direct actions (sing, play_music) give moderate XP
|
||||
* - XP accumulates across all life stages and never resets
|
||||
*/
|
||||
|
||||
@@ -17,19 +17,18 @@ import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-actio
|
||||
// ─── XP Values by Action ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base XP values for inventory actions (feed, play, clean, medicine).
|
||||
* These actions consume items from the player's storage.
|
||||
* Base XP values for item-based care actions (feed, play, clean, medicine).
|
||||
*/
|
||||
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
|
||||
feed: 5, // Feeding is common and essential - moderate XP
|
||||
play: 8, // Playing toys provides good interaction - higher XP
|
||||
clean: 6, // Hygiene maintenance is important - moderate-high XP
|
||||
medicine: 10, // Medicine is costly and critical - highest inventory XP
|
||||
medicine: 10, // Medicine is critical - highest item XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Base XP values for direct actions (play_music, sing).
|
||||
* These actions don't consume items - they're free activities.
|
||||
* These actions don't require selecting an item.
|
||||
*/
|
||||
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
|
||||
play_music: 7, // Playing music is engaging - good XP
|
||||
@@ -58,11 +57,10 @@ export function calculateActionXP(action: BlobbiAction): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total XP gain for using multiple items.
|
||||
* Each item use counts as a separate action for XP purposes.
|
||||
* Calculate XP gain for an item-based care action.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of items used (defaults to 1)
|
||||
* @param quantity - Number of times performed (always 1 in current usage)
|
||||
* @returns Total XP points earned
|
||||
*/
|
||||
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
|
||||
@@ -88,8 +86,8 @@ export function applyXPGain(currentXP: number | undefined, xpGain: number): numb
|
||||
* Get XP gain summary for displaying to the user.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of times the action was performed (for inventory actions)
|
||||
* @returns Object with xpGained and total quantity
|
||||
* @param quantity - Number of times the action was performed (always 1 in current usage)
|
||||
* @returns Object with xpGained and quantity
|
||||
*/
|
||||
export function getXPGainSummary(
|
||||
action: BlobbiAction,
|
||||
|
||||
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await contextUseItem(item.id, action, 1);
|
||||
const result = await contextUseItem(item.id, action);
|
||||
|
||||
if (result.success) {
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
@@ -17,12 +17,14 @@ import { useMemo, memo, type RefObject } from 'react';
|
||||
|
||||
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
|
||||
|
||||
@@ -248,7 +250,14 @@ export function BlobbiCompanionVisual({
|
||||
)}
|
||||
style={{ transformOrigin: 'center bottom' }}
|
||||
>
|
||||
{(companion.stage === 'baby' || companion.stage === 'adult') && (
|
||||
{companion.stage === 'egg' ? (
|
||||
<BlobbiStageVisual
|
||||
companion={companion as unknown as BlobbiCompanion}
|
||||
size="sm"
|
||||
animated={false}
|
||||
className="size-full"
|
||||
/>
|
||||
) : (
|
||||
<MemoizedBlobbiVisual
|
||||
stage={companion.stage}
|
||||
blobbi={blobbi}
|
||||
|
||||
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging - let gravity take over.
|
||||
* End dragging - hold position where dropped.
|
||||
*/
|
||||
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
|
||||
return {
|
||||
...motion,
|
||||
isDragging: false,
|
||||
// If already at or below ground, snap to ground
|
||||
isGrounded: motion.position.y >= groundY,
|
||||
// Always treat as grounded so companion holds position where dropped
|
||||
isGrounded: true,
|
||||
position: {
|
||||
...motion.position,
|
||||
y: motion.position.y >= groundY ? groundY : motion.position.y,
|
||||
// Clamp to ground if below it
|
||||
y: Math.min(motion.position.y, groundY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
// Track if first entry has completed (for position initialization)
|
||||
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
|
||||
|
||||
// Track viewport size
|
||||
// Track viewport size — listen to both window resize and visualViewport
|
||||
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setViewport({
|
||||
@@ -114,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate bounds and positions
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
* Fetches the current companion data from the user's Blobbonaut profile.
|
||||
* This is the data layer - it handles fetching and provides companion data.
|
||||
*
|
||||
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
|
||||
* useBlobbisCollection. This ensures:
|
||||
* - Immediate reactivity when stats change (optimistic updates)
|
||||
* - Projected decay is applied for accurate visual reactions
|
||||
* - No duplicate queries or stale cache issues
|
||||
* Uses useBlobbisCollection with a targeted dList (single d-tag) for efficiency.
|
||||
* Optimistic updates from mutations propagate across all blobbi-collection
|
||||
* queries (including BlobbiPage's 'all' mode) via updateCompanionEvent.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -32,16 +30,14 @@ interface UseBlobbiCompanionDataResult {
|
||||
*
|
||||
* Flow:
|
||||
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
|
||||
* 2. Build a dList containing just the currentCompanion
|
||||
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
|
||||
* 2. Build a dList containing just the currentCompanion (targeted fetch)
|
||||
* 3. Use useBlobbisCollection with the dList to get the companion
|
||||
* 4. Apply projected decay for accurate UI reactions
|
||||
* 5. Return the companion data with projected stats
|
||||
*
|
||||
* Reactivity:
|
||||
* - Uses the same query cache as BlobbiPage (blobbi-collection)
|
||||
* - When Blobbi state is updated, optimistic updates flow through immediately
|
||||
* - Projected decay recalculates every 60 seconds
|
||||
* - No separate query or stale cache issues
|
||||
* - Optimistic updates propagate across all blobbi-collection queries
|
||||
* - Projected decay recalculates every 60 seconds while mounted
|
||||
*/
|
||||
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
// Use the shared profile hook - this ensures reactivity when profile changes
|
||||
@@ -80,9 +76,6 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
|
||||
if (!blobbi) return null;
|
||||
|
||||
// Only baby and adult can be companions
|
||||
if (blobbi.stage === 'egg') return null;
|
||||
|
||||
// Use projected stats if available, otherwise fall back to base stats
|
||||
const stats = projectedState?.stats ?? blobbi.stats;
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export function useBlobbiCompanionState({
|
||||
setState('walking');
|
||||
setDirection('right');
|
||||
setTargetX(targetX);
|
||||
}, [bounds.maxX]);
|
||||
}, [bounds.maxX, motionRef]);
|
||||
|
||||
/**
|
||||
* Generate a random observation target on screen.
|
||||
@@ -136,7 +136,7 @@ export function useBlobbiCompanionState({
|
||||
setState('walking');
|
||||
setDirection(newDirection);
|
||||
setTargetX(targetXPos);
|
||||
}, [bounds, generateObservationTarget]);
|
||||
}, [bounds, generateObservationTarget, motionRef]);
|
||||
|
||||
// Make a decision about what to do next
|
||||
const makeDecision = useCallback(() => {
|
||||
@@ -176,7 +176,7 @@ export function useBlobbiCompanionState({
|
||||
// Schedule next decision
|
||||
const duration = transition.duration ?? randomDuration(config.idleTime);
|
||||
timerRef.current = window.setTimeout(makeDecision, duration);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation]);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation, motionRef]);
|
||||
|
||||
// Handle reaching target
|
||||
const onReachedTarget = useCallback(() => {
|
||||
@@ -255,7 +255,7 @@ export function useBlobbiCompanionState({
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
|
||||
|
||||
// Pause decisions while dragging
|
||||
// We poll isDragging via interval since motionRef changes don't trigger re-renders
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
* idle -> rising -> inspecting -> entering -> complete
|
||||
*
|
||||
* Route change behavior:
|
||||
* - Cancels current entry immediately
|
||||
* - Waits 1 second
|
||||
* - Restarts entry for the new page
|
||||
* - Companion keeps its current position (no re-entry animation)
|
||||
* - Only initial mount and companion changes trigger entry animations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -310,20 +309,11 @@ export function useBlobbiEntryAnimation({
|
||||
// Random entry type for new companion (fall or rise)
|
||||
const entryType: EntryType = Math.random() < 0.5 ? 'fall' : 'rise';
|
||||
startEntry(entryType);
|
||||
} else if (routeChanged && companionId) {
|
||||
// Route changed - determine direction for new route
|
||||
const entryType = getEntryDirection(previousPath, pathname, sidebarOrder);
|
||||
|
||||
// Immediately hide Blobbi and cancel current entry
|
||||
cancelEntry();
|
||||
setIsHiddenForTransition(true);
|
||||
|
||||
// Wait 1 second, then start the new entry animation
|
||||
routeChangeTimeoutRef.current = setTimeout(() => {
|
||||
startEntry(entryType);
|
||||
}, entryConfig.routeChangeRestartDelay);
|
||||
} else if (routeChanged) {
|
||||
// Route changed - companion keeps its position, no re-entry animation.
|
||||
// Just update the ref so future changes compare against the new path.
|
||||
}
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
|
||||
|
||||
/**
|
||||
* Animation loop for FALL entry.
|
||||
|
||||
+7
-9
@@ -15,17 +15,15 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
|
||||
import { useBlobbiItemUse } from './useBlobbiItemUse';
|
||||
import {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type BlobbiActionsContextValue,
|
||||
type BlobbiActionsContextInternal,
|
||||
} from './BlobbiActionsProvider';
|
||||
} from './BlobbiActionsContextDef';
|
||||
|
||||
// Re-export everything from the provider module for backward compatibility
|
||||
// Re-export types and context from the def module for backward compatibility
|
||||
export {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type BlobbiActionsContextValue,
|
||||
@@ -64,13 +62,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
// Create stable useItem function that:
|
||||
// 1. Uses registered function if available (from BlobbiPage)
|
||||
// 2. Falls back to built-in hook if no registration
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
// Try registered function first (from BlobbiPage)
|
||||
if (context?.registerRef.current) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[BlobbiActions] Using registered item-use function');
|
||||
}
|
||||
return context.registerRef.current(itemId, action, quantity);
|
||||
return context.registerRef.current(itemId, action);
|
||||
}
|
||||
|
||||
// Check if fallback can handle it
|
||||
@@ -88,7 +86,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[BlobbiActions] Using fallback item-use hook');
|
||||
}
|
||||
return fallbackItemUse.useItem(itemId, action, quantity);
|
||||
return fallbackItemUse.useItem(itemId, action);
|
||||
}, [context, fallbackItemUse]);
|
||||
|
||||
// Determine canUseItems: true if registered OR fallback can use
|
||||
@@ -136,14 +134,14 @@ export function useBlobbiActionsRegistration(
|
||||
useItemRef.current = useItemFn;
|
||||
|
||||
// Create a stable wrapper that delegates to the ref
|
||||
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
if (!useItemRef.current) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Item use function not available',
|
||||
};
|
||||
}
|
||||
return useItemRef.current(itemId, action, quantity);
|
||||
return useItemRef.current(itemId, action);
|
||||
}, []);
|
||||
|
||||
// Update refs and notify only when canUseItems actually changes
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* BlobbiActionsContextDef
|
||||
*
|
||||
* Lightweight context definition and types for the Blobbi actions system.
|
||||
* Separated from the provider component to avoid react-refresh warnings.
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of using an item via the context.
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
/** Whether the use was successful */
|
||||
success: boolean;
|
||||
/** Stats that changed (key = stat name, value = delta) */
|
||||
statsChanged?: Record<string, number>;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function signature for using an item (always uses once).
|
||||
*/
|
||||
export type UseItemFunction = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
) => Promise<UseItemResult>;
|
||||
|
||||
/**
|
||||
* Context value for Blobbi actions (consumer side).
|
||||
*/
|
||||
export interface BlobbiActionsContextValue {
|
||||
/**
|
||||
* Use an item on the current companion.
|
||||
* Works even without BlobbiPage registration (uses fallback).
|
||||
*/
|
||||
useItem: UseItemFunction;
|
||||
|
||||
/** Whether an item use operation is currently in progress */
|
||||
isUsingItem: boolean;
|
||||
|
||||
/** Whether items can be used (companion exists and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context value (includes registration functions).
|
||||
*/
|
||||
export interface BlobbiActionsContextInternal {
|
||||
/** Register item-use functionality (called by BlobbiPage) */
|
||||
registerRef: React.MutableRefObject<UseItemFunction | null>;
|
||||
/** Whether items can currently be used (via registration) */
|
||||
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Whether an item is currently being used (via registration) */
|
||||
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Force update consumers (called sparingly) */
|
||||
notifyUpdate: () => void;
|
||||
/** Subscribe to updates */
|
||||
subscribe: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
|
||||
@@ -10,75 +10,13 @@
|
||||
* BlobbiPage, both of which are lazy-loaded.
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
|
||||
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of using an item via the context.
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
/** Whether the use was successful */
|
||||
success: boolean;
|
||||
/** Stats that changed (key = stat name, value = delta) */
|
||||
statsChanged?: Record<string, number>;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function signature for using an item.
|
||||
*/
|
||||
export type UseItemFunction = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
quantity?: number
|
||||
) => Promise<UseItemResult>;
|
||||
|
||||
/**
|
||||
* Context value for Blobbi actions (consumer side).
|
||||
*/
|
||||
export interface BlobbiActionsContextValue {
|
||||
/**
|
||||
* Use an inventory item on the current companion.
|
||||
* Works even without BlobbiPage registration (uses fallback).
|
||||
*/
|
||||
useItem: UseItemFunction;
|
||||
|
||||
/** Whether an item use operation is currently in progress */
|
||||
isUsingItem: boolean;
|
||||
|
||||
/** Whether items can be used (companion exists and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context value (includes registration functions).
|
||||
*/
|
||||
export interface BlobbiActionsContextInternal {
|
||||
/** Register item-use functionality (called by BlobbiPage) */
|
||||
registerRef: React.MutableRefObject<UseItemFunction | null>;
|
||||
/** Whether items can currently be used (via registration) */
|
||||
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Whether an item is currently being used (via registration) */
|
||||
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Force update consumers (called sparingly) */
|
||||
notifyUpdate: () => void;
|
||||
/** Subscribe to updates */
|
||||
subscribe: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
|
||||
import {
|
||||
BlobbiActionsContext,
|
||||
type UseItemFunction,
|
||||
type BlobbiActionsContextInternal,
|
||||
} from './BlobbiActionsContextDef';
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
/**
|
||||
* HangingItems
|
||||
*
|
||||
* Displays inventory items as hanging elements from the top of the screen.
|
||||
* Displays available items as hanging elements from the top of the screen.
|
||||
* Each item appears as a circle connected to the top by a thin vertical line,
|
||||
* creating a playful, spatial feel.
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — they are
|
||||
* always available and not consumed on use.
|
||||
*
|
||||
* State Model:
|
||||
* - Container states: hidden → opening → open → closing → hidden
|
||||
* - Hanging items = available inventory that can still be released
|
||||
* - Hanging items = catalog items available for the selected action
|
||||
* - Released/dropped items = instances currently in the world (tracked with unique IDs)
|
||||
* - Multiple instances of the same item type can exist simultaneously on the ground
|
||||
*
|
||||
* Key Design Principle:
|
||||
* The hanging row represents "releasable quantity" - clicking releases ONE instance
|
||||
* and immediately decrements the visible quantity. A new hanging copy remains if
|
||||
* quantity > 1. The released instance tracks separately with a unique instance ID.
|
||||
*
|
||||
* Features:
|
||||
* - Smooth open/close slide animations (items descend/ascend)
|
||||
* - Thin vertical lines from the top of screen
|
||||
* - Circular containers for hanging items
|
||||
* - Click releases item: one instance falls, remaining quantity stays hanging
|
||||
* - Click releases item: one instance falls to the ground
|
||||
* - Multiple dropped instances of same item type can exist
|
||||
* - Contact detection: items auto-use when touching Blobbi
|
||||
* - Click-to-use: click landed items to use them
|
||||
@@ -119,7 +117,7 @@ interface HangingItemsProps {
|
||||
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
|
||||
/**
|
||||
* Callback when an item is collected by Blobbi (contact).
|
||||
* @deprecated Use onItemUse instead for proper item consumption flow.
|
||||
* @deprecated Use onItemUse instead for proper item-use flow.
|
||||
*/
|
||||
onItemCollected?: (item: CompanionItem) => void;
|
||||
/**
|
||||
@@ -156,7 +154,7 @@ const HANGING_CONFIG = {
|
||||
baseFallDistance: 500,
|
||||
/** Ground offset from bottom of viewport */
|
||||
defaultGroundOffset: 40,
|
||||
/** Size of quantity badge */
|
||||
/** Size of badge (unused — kept for config consistency) */
|
||||
badgeSize: 20,
|
||||
/** Size of landed item hitbox for contact detection */
|
||||
landedItemSize: 40,
|
||||
@@ -406,7 +404,7 @@ export function HangingItems({
|
||||
|
||||
// Track how many instances of each item type have been released (not yet used)
|
||||
// Key: item.id (type ID), Value: count of released instances
|
||||
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Counter for generating unique instance IDs
|
||||
const instanceCounterRef = useRef(0);
|
||||
@@ -566,7 +564,7 @@ export function HangingItems({
|
||||
|
||||
// Start the loop
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}, []);
|
||||
}, [calculateFallDuration]);
|
||||
|
||||
// Cleanup animation on unmount
|
||||
useEffect(() => {
|
||||
@@ -670,7 +668,7 @@ export function HangingItems({
|
||||
});
|
||||
// Also remove from zone tracking
|
||||
itemsInZoneRef.current.delete(instanceId);
|
||||
// Decrement the released count for this item type (since the instance is now consumed)
|
||||
// Decrement the released count for this item type (instance removed from screen)
|
||||
setReleasedCountByItemId(prev => {
|
||||
const next = new Map(prev);
|
||||
const currentCount = next.get(item.id) || 0;
|
||||
@@ -985,15 +983,9 @@ export function HangingItems({
|
||||
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
|
||||
};
|
||||
|
||||
// Calculate hanging items with their remaining quantities
|
||||
// An item appears in the hanging row if (quantity - releasedCount) > 0
|
||||
const hangingItems = items
|
||||
.map(item => {
|
||||
const releasedCount = releasedCountByItemId.get(item.id) || 0;
|
||||
const remainingQuantity = item.quantity - releasedCount;
|
||||
return { ...item, quantity: remainingQuantity };
|
||||
})
|
||||
.filter(item => item.quantity > 0);
|
||||
// All items are always visible — they are abilities, not consumable inventory.
|
||||
// No quantity filtering needed.
|
||||
const hangingItems = items;
|
||||
|
||||
// Should we render the hanging container?
|
||||
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
|
||||
@@ -1033,7 +1025,7 @@ export function HangingItems({
|
||||
>
|
||||
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1102,8 +1094,8 @@ export function HangingItems({
|
||||
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
|
||||
}}
|
||||
onClick={() => handleItemClick(item, itemX)}
|
||||
title={`${item.name} (x${item.quantity})`}
|
||||
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
|
||||
title={item.name}
|
||||
aria-label={`${item.name}. Click to release.`}
|
||||
>
|
||||
{/* Item emoji */}
|
||||
<span
|
||||
@@ -1114,24 +1106,6 @@ export function HangingItems({
|
||||
>
|
||||
{item.emoji}
|
||||
</span>
|
||||
|
||||
{/* Quantity badge */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1",
|
||||
"flex items-center justify-center",
|
||||
"bg-primary text-primary-foreground",
|
||||
"text-xs font-semibold rounded-full",
|
||||
"shadow-md"
|
||||
)}
|
||||
style={{
|
||||
minWidth: HANGING_CONFIG.badgeSize,
|
||||
height: HANGING_CONFIG.badgeSize,
|
||||
padding: '0 5px',
|
||||
}}
|
||||
>
|
||||
{item.quantity}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
|
||||
|
||||
/**
|
||||
* Normalized item representation for the companion UI.
|
||||
* This is a simplified view of inventory items optimized for rendering.
|
||||
* This is a simplified view of shop catalog items optimized for rendering.
|
||||
*/
|
||||
export interface CompanionItem {
|
||||
/** Unique item ID (matches shop item ID) */
|
||||
@@ -74,7 +74,7 @@ export interface CompanionItem {
|
||||
emoji: string;
|
||||
/** Item category */
|
||||
category: ShopItemCategory;
|
||||
/** Quantity available in inventory */
|
||||
/** Availability (always Infinity — items are reusable abilities) */
|
||||
quantity: number;
|
||||
/** Item effects when used */
|
||||
effect?: ItemEffect;
|
||||
|
||||
@@ -27,13 +27,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
parseBlobbiEvent,
|
||||
isValidBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseItemForStage,
|
||||
getStageRestrictionMessage,
|
||||
@@ -59,7 +55,7 @@ import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
|
||||
|
||||
import type { UseItemFunction } from './BlobbiActionsProvider';
|
||||
import type { UseItemFunction } from './BlobbiActionsContextDef';
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch profile if not provided
|
||||
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
|
||||
const { profile: fetchedProfile } = useBlobbonautProfile();
|
||||
const profile = options.profile ?? fetchedProfile;
|
||||
|
||||
// Per-item cooldown tracking (ref to avoid re-renders)
|
||||
@@ -232,16 +228,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
});
|
||||
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
|
||||
|
||||
// Core mutation for using items
|
||||
// Core mutation for using items (always uses once)
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
itemId,
|
||||
action,
|
||||
quantity = 1,
|
||||
}: {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
quantity?: number;
|
||||
}): Promise<{ statsChanged: Record<string, number> }> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
@@ -259,11 +253,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
// Validate quantity
|
||||
if (quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
// Check stage restrictions
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
@@ -283,15 +272,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -319,17 +299,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
const isEggCompanion = companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
}
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
@@ -339,15 +315,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
const hygieneDelta = shopItem.effect.hygiene ?? 0;
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
}
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
@@ -362,11 +331,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentStats = applyItemEffects(currentStats, shopItem.effect);
|
||||
}
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
@@ -414,36 +380,19 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
|
||||
updateCompanionInCache(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(profile.allTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
// Items are free to use — no storage decrement needed.
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
|
||||
|
||||
return { statsChanged };
|
||||
},
|
||||
onSuccess: (_, { itemId, action, quantity = 1 }) => {
|
||||
onSuccess: (_, { itemId, action }) => {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
|
||||
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
@@ -468,7 +417,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
});
|
||||
|
||||
// Wrapper function that matches UseItemFunction signature and includes cooldown check
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
// Check cooldown first
|
||||
if (isItemOnCooldown(itemId)) {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -481,7 +430,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mutation.mutateAsync({ itemId, action, quantity });
|
||||
const result = await mutation.mutateAsync({ itemId, action });
|
||||
return {
|
||||
success: true,
|
||||
statsChanged: result.statsChanged,
|
||||
|
||||
@@ -69,14 +69,13 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
/** Optimistically update the TanStack cache so the companion reacts immediately. */
|
||||
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
return;
|
||||
}
|
||||
if (!parsed) return;
|
||||
|
||||
// Optimistically update ALL blobbi-collection queries for this user.
|
||||
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
|
||||
// partial matching to find all entries regardless of dList shape.
|
||||
// No invalidation needed — we fetched fresh from relays before mutating,
|
||||
// so the optimistic update is the correct state.
|
||||
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
|
||||
const matchingQueries = queryClient.getQueriesData<CollectionData>({
|
||||
queryKey: ['blobbi-collection', pubkey],
|
||||
@@ -90,9 +89,6 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// Also invalidate for background refetch to ensure eventual consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
}, [queryClient]);
|
||||
|
||||
const toggleSleep = useCallback(async () => {
|
||||
|
||||
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
import type {
|
||||
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve inventory items for a specific action/category.
|
||||
* Resolve available items for a specific action/category from the shop catalog.
|
||||
*
|
||||
* Items are sourced from the full shop catalog — all items are
|
||||
* available as reusable abilities/tools, filtered only by stage.
|
||||
*
|
||||
* Uses the centralized `canUseItemForStage` function to ensure consistent
|
||||
* stage-based filtering across all UIs:
|
||||
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
|
||||
* filters out all egg-only items from the companion interaction system.
|
||||
*/
|
||||
function resolveItemsForAction(
|
||||
storage: StorageItem[],
|
||||
action: CompanionMenuAction,
|
||||
stage: 'egg' | 'baby' | 'adult'
|
||||
): CompanionItem[] {
|
||||
@@ -89,13 +90,10 @@ function resolveItemsForAction(
|
||||
// Sleep action has no items
|
||||
if (!category) return [];
|
||||
|
||||
const allItems = getLiveShopItems();
|
||||
const items: CompanionItem[] = [];
|
||||
|
||||
for (const storageItem of storage) {
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== category) continue;
|
||||
|
||||
// Use centralized stage-based filtering
|
||||
@@ -104,17 +102,17 @@ function resolveItemsForAction(
|
||||
// - Food/Toys: only for baby/adult (excluded for eggs)
|
||||
// - Medicine: must have health effect
|
||||
// - Hygiene: must have hygiene or happiness effect
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
const usability = canUseItemForStage(shopItem.id, stage);
|
||||
if (!usability.canUse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: storageItem.itemId,
|
||||
id: shopItem.id,
|
||||
name: shopItem.name,
|
||||
emoji: shopItem.icon,
|
||||
category: shopItem.type,
|
||||
quantity: storageItem.quantity,
|
||||
quantity: Infinity,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve items for this action
|
||||
const items = resolveItemsForAction(profile.storage, action, stage);
|
||||
// Resolve items for this action from the catalog (not inventory)
|
||||
const items = resolveItemsForAction(action, stage);
|
||||
|
||||
setMenuState(prev => ({
|
||||
...prev,
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface ItemUseResult {
|
||||
export type UseItemCallback = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
quantity: number
|
||||
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
|
||||
|
||||
/**
|
||||
@@ -67,14 +66,14 @@ export interface UseCompanionItemUseResult {
|
||||
isUsingItem: boolean;
|
||||
/** Get the action type for an item category */
|
||||
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
|
||||
/** Get the inventory action for a menu action */
|
||||
/** Get the care action for a menu action */
|
||||
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map item categories to inventory actions.
|
||||
* Map item categories to care actions.
|
||||
* This is the canonical mapping for how items are used.
|
||||
*/
|
||||
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
|
||||
@@ -85,14 +84,14 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
|
||||
};
|
||||
|
||||
/**
|
||||
* Map menu actions to inventory actions (they match by design).
|
||||
* Map menu actions to item-based care actions (they match by design).
|
||||
*/
|
||||
export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, InventoryAction | null> = {
|
||||
feed: 'feed',
|
||||
play: 'play',
|
||||
medicine: 'medicine',
|
||||
clean: 'clean',
|
||||
sleep: null, // Sleep is a special action, not an inventory action
|
||||
sleep: null, // Sleep is a special action, not item-based
|
||||
};
|
||||
|
||||
// ─── Hook Implementation ──────────────────────────────────────────────────────
|
||||
@@ -108,8 +107,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { useItem, isUsingItem } = useCompanionItemUse({
|
||||
* onUseItem: async (itemId, action, qty) => {
|
||||
* return await executeUseItem({ itemId, action, quantity: qty });
|
||||
* onUseItem: async (itemId, action) => {
|
||||
* return await executeUseItem({ itemId, action });
|
||||
* },
|
||||
* onSuccess: (result) => removeItemFromScreen(result.item),
|
||||
* onFailure: (result) => keepItemOnScreen(result.item),
|
||||
@@ -134,7 +133,7 @@ export function useCompanionItemUse({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the inventory action for a menu action.
|
||||
* Get the care action for a menu action.
|
||||
*/
|
||||
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
|
||||
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
|
||||
@@ -187,7 +186,7 @@ export function useCompanionItemUse({
|
||||
|
||||
try {
|
||||
// Execute the use callback
|
||||
const useResult = await onUseItem(item.id, inventoryAction, 1);
|
||||
const useResult = await onUseItem(item.id, inventoryAction);
|
||||
|
||||
if (useResult.success) {
|
||||
const result: ItemUseResult = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -8,12 +9,18 @@ import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
BLOBBONAUT_PROFILE_KINDS,
|
||||
getBlobbonautQueryDValues,
|
||||
buildMigrationTags,
|
||||
generatePetId10,
|
||||
getCanonicalBlobbiD,
|
||||
isValidBlobbiEvent,
|
||||
isValidBlobbonautEvent,
|
||||
isLegacyBlobbonautKind,
|
||||
migratePetInHas,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbiEvent,
|
||||
parseBlobbonautEvent,
|
||||
parseStorageTags,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
@@ -52,10 +59,6 @@ export interface EnsureCanonicalOptions {
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update localStorage selection if it was pointing to legacy d */
|
||||
updateStoredSelectedD?: (newD: string) => void;
|
||||
/** Callback to invalidate companion query */
|
||||
invalidateCompanion?: () => void;
|
||||
/** Callback to invalidate profile query */
|
||||
invalidateProfile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +114,7 @@ export interface EnsureCanonicalResult {
|
||||
* ```
|
||||
*/
|
||||
export function useBlobbiMigration() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -134,8 +138,6 @@ export function useBlobbiMigration() {
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
} = options;
|
||||
|
||||
if (!user?.pubkey) {
|
||||
@@ -190,7 +192,8 @@ export function useBlobbiMigration() {
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
// Update query caches
|
||||
// Update query caches (optimistic — no invalidation needed since we
|
||||
// fetch fresh from relays before every mutation)
|
||||
updateProfileEvent(profileEvent);
|
||||
updateCompanionEvent(canonicalEvent);
|
||||
|
||||
@@ -200,10 +203,6 @@ export function useBlobbiMigration() {
|
||||
updateStoredSelectedD(canonicalD);
|
||||
}
|
||||
|
||||
// Invalidate queries to refetch fresh data
|
||||
invalidateCompanion?.();
|
||||
invalidateProfile?.();
|
||||
|
||||
toast({
|
||||
title: 'Pet upgraded!',
|
||||
description: `${companion.name} has been migrated to the new format.`,
|
||||
@@ -237,29 +236,102 @@ export function useBlobbiMigration() {
|
||||
}
|
||||
}, [user?.pubkey, publishEvent]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest companion event directly from relays, bypassing cache.
|
||||
* This is the read step of the read-modify-write pattern.
|
||||
*/
|
||||
const fetchFreshCompanion = useCallback(async (
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<BlobbiCompanion | null> => {
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag],
|
||||
}]);
|
||||
|
||||
const validEvents = events
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest profile event directly from relays, bypassing cache.
|
||||
*/
|
||||
const fetchFreshProfile = useCallback(async (
|
||||
pubkey: string,
|
||||
): Promise<BlobbonautProfile | null> => {
|
||||
const dValues = getBlobbonautQueryDValues(pubkey);
|
||||
const events = await nostr.query([{
|
||||
kinds: [...BLOBBONAUT_PROFILE_KINDS],
|
||||
authors: [pubkey],
|
||||
'#d': dValues,
|
||||
}]);
|
||||
|
||||
const validEvents = events.filter(isValidBlobbonautEvent);
|
||||
if (validEvents.length === 0) return null;
|
||||
|
||||
// Prefer current kind over legacy
|
||||
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
|
||||
if (currentKindEvents.length > 0) {
|
||||
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
|
||||
if (legacyKindEvents.length > 0) {
|
||||
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Ensure a Blobbi is in canonical format before performing an action.
|
||||
*
|
||||
* CRITICAL: This fetches fresh data from relays (read-modify-write pattern)
|
||||
* instead of using potentially stale cache data. This prevents state resets
|
||||
* caused by publishing over a newer event with stale cached data.
|
||||
*
|
||||
* If the companion is legacy, it will be migrated first.
|
||||
* Returns the canonical companion to use for the action.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if Blobbi is legacy
|
||||
* 2. If legacy: migrate Blobbi
|
||||
* 3. Return the resolved canonical Blobbi
|
||||
* 1. Fetch fresh companion + profile from relays
|
||||
* 2. Check if Blobbi is legacy
|
||||
* 3. If legacy: migrate Blobbi
|
||||
* 4. Return the resolved canonical Blobbi with fresh data
|
||||
*
|
||||
* All interaction handlers should call this before publishing events.
|
||||
*/
|
||||
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<EnsureCanonicalResult | null> => {
|
||||
const { companion, profile } = options;
|
||||
if (!user?.pubkey) return null;
|
||||
|
||||
const { companion: cachedCompanion, profile: cachedProfile } = options;
|
||||
|
||||
// Fetch fresh data from relays (read step of read-modify-write)
|
||||
const [freshCompanion, freshProfile] = await Promise.all([
|
||||
fetchFreshCompanion(user.pubkey, cachedCompanion.d),
|
||||
fetchFreshProfile(user.pubkey),
|
||||
]);
|
||||
|
||||
// Use fresh data, falling back to cached only if relay fetch returned nothing
|
||||
const companion = freshCompanion ?? cachedCompanion;
|
||||
const profile = freshProfile ?? cachedProfile;
|
||||
|
||||
// Check if the companion needs migration
|
||||
if (companion.isLegacy) {
|
||||
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
|
||||
|
||||
const migrationResult = await migrateLegacyBlobbi(options);
|
||||
// Use fresh data in migration options
|
||||
const migrationOptions = { ...options, companion, profile };
|
||||
const migrationResult = await migrateLegacyBlobbi(migrationOptions);
|
||||
|
||||
if (!migrationResult) {
|
||||
// Migration failed, cannot proceed with action
|
||||
@@ -279,7 +351,7 @@ export function useBlobbiMigration() {
|
||||
};
|
||||
}
|
||||
|
||||
// Companion is already canonical, return profile as-is
|
||||
// Companion is already canonical, return fresh data
|
||||
return {
|
||||
wasMigrated: false,
|
||||
companion,
|
||||
@@ -288,7 +360,7 @@ export function useBlobbiMigration() {
|
||||
profileAllTags: profile.allTags,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [migrateLegacyBlobbi]);
|
||||
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
|
||||
|
||||
return {
|
||||
/** Migrate a legacy Blobbi to canonical format */
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
BLOBBI_ECOSYSTEM_NAMESPACE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
@@ -26,62 +27,90 @@ function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
* Hook to fetch Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
*
|
||||
* Two modes:
|
||||
* - **No dList** (default): Fetches ALL the user's blobbi events by author +
|
||||
* ecosystem namespace tag. This is the authoritative source of truth —
|
||||
* the user authored these events, so we don't need a secondary index.
|
||||
* - **With dList**: Fetches only the specified d-tags. Use this when you only
|
||||
* need a specific subset (e.g. the companion layer needs just one blobbi).
|
||||
*
|
||||
* Features:
|
||||
* - Fetches ALL pets by d-tag list (no limit: 1)
|
||||
* - Chunks large d-lists into multiple queries for relay compatibility
|
||||
* - Keeps only the newest event per d-tag
|
||||
* - Returns both a lookup record and array of companions
|
||||
* - Provides invalidation and optimistic update helpers
|
||||
*/
|
||||
export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
export function useBlobbisCollection(dList?: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Create a stable query key based on sorted d-tags
|
||||
// Determine the mode: 'all' fetches everything, 'dlist' fetches by specific d-tags
|
||||
const mode = dList === undefined ? 'all' : 'dlist';
|
||||
|
||||
// Create a stable query key based on sorted d-tags (for dlist mode)
|
||||
const sortedDList = useMemo(() => {
|
||||
if (!dList || dList.length === 0) return null;
|
||||
if (mode === 'all' || !dList || dList.length === 0) return null;
|
||||
return [...dList].sort();
|
||||
}, [dList]);
|
||||
}, [mode, dList]);
|
||||
|
||||
const queryKeyDTags = sortedDList?.join(',') ?? '';
|
||||
// Query key segment: 'all' for fetch-all mode, comma-joined d-tags for dlist mode
|
||||
const queryKeySegment = mode === 'all' ? 'all' : (sortedDList?.join(',') ?? '');
|
||||
|
||||
// Main query to fetch all companions from relays
|
||||
// Main query to fetch companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeySegment],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
|
||||
if (!user?.pubkey) {
|
||||
console.log('[useBlobbisCollection] No pubkey, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
// Log the dList we're about to query
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
let allEvents: NostrEvent[];
|
||||
|
||||
// Chunk the d-list for relay compatibility
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
// Query all chunks in parallel
|
||||
const allEvents: NostrEvent[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (mode === 'all') {
|
||||
// Fetch ALL the user's blobbi events — author is the source of truth
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
|
||||
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
|
||||
};
|
||||
|
||||
// Log the filter immediately before query
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
console.log('[Blobbi] 31124 query filter (all):', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
allEvents = await nostr.query([filter], { signal });
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
console.log('[useBlobbisCollection] Fetch-all returned', allEvents.length, 'events');
|
||||
} else {
|
||||
// Fetch by specific d-tags (for companion layer etc.)
|
||||
if (!sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] Empty dList, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
allEvents = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
};
|
||||
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
|
||||
@@ -123,7 +152,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
enabled: !!user?.pubkey && (mode === 'all' || (!!sortedDList && sortedDList.length > 0)),
|
||||
staleTime: 30_000, // 30 seconds
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -132,46 +161,51 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
|
||||
// Helper to invalidate and refetch after publishing
|
||||
// Helper to invalidate and refetch after publishing.
|
||||
// NOTE: In most mutation paths this is no longer needed — the read-modify-write
|
||||
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
|
||||
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
|
||||
const invalidate = useCallback(() => {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeySegment],
|
||||
});
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
}, [queryClient, user?.pubkey, queryKeySegment]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update)
|
||||
// Update a single companion event in the query cache (optimistic update).
|
||||
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
|
||||
// one matching the current queryKeySegment. This ensures the BlobbiPage cache
|
||||
// and companion layer cache stay in sync (they use different query modes).
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
(prev) => {
|
||||
if (!prev) {
|
||||
return {
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
};
|
||||
}
|
||||
|
||||
// Update the specific companion in the record
|
||||
const newCompanionsByD = {
|
||||
...prev.companionsByD,
|
||||
[parsed.d]: parsed,
|
||||
};
|
||||
|
||||
// Rebuild companions array from the record
|
||||
const newCompanions = Object.values(newCompanionsByD);
|
||||
|
||||
return {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: newCompanions,
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
|
||||
const matchingQueries = queryClient.getQueriesData<CollectionData>({
|
||||
queryKey: ['blobbi-collection', user.pubkey],
|
||||
});
|
||||
|
||||
for (const [queryKey, data] of matchingQueries) {
|
||||
if (!data) continue;
|
||||
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
|
||||
queryClient.setQueryData<CollectionData>(queryKey, {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// If no existing queries matched (first load), set our own query key
|
||||
if (matchingQueries.length === 0) {
|
||||
queryClient.setQueryData<CollectionData>(
|
||||
['blobbi-collection', user.pubkey, queryKeySegment],
|
||||
{
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeySegment]);
|
||||
|
||||
// Memoize return values for stability
|
||||
const companionsByD = query.data?.companionsByD ?? {};
|
||||
@@ -190,7 +224,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
isStale: query.isStale,
|
||||
/** Query error if any */
|
||||
error: query.error,
|
||||
/** Invalidate and refetch the collection */
|
||||
/** Invalidate and refetch the collection (use only when d-tag set changes, not after mutations) */
|
||||
invalidate,
|
||||
/** Optimistically update a single companion in the cache */
|
||||
updateCompanionEvent,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -288,7 +288,7 @@ export interface BlobbiCompanion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored item in user's profile inventory
|
||||
* Stored item in user's profile (from purchases)
|
||||
*/
|
||||
export interface StorageItem {
|
||||
itemId: string; // Must match a ShopItem.id
|
||||
@@ -316,7 +316,7 @@ export interface BlobbonautProfile {
|
||||
coins: number;
|
||||
/** Petting level (interaction counter) */
|
||||
pettingLevel: number;
|
||||
/** Purchased items inventory */
|
||||
/** Purchased items storage */
|
||||
storage: StorageItem[];
|
||||
/** All tags preserved for republishing */
|
||||
allTags: string[][];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
@@ -27,18 +27,6 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Tour dev actions for the first-hatch tour */
|
||||
interface FirstHatchTourDevActions {
|
||||
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
|
||||
skipPostRequirement: () => void;
|
||||
/** Reset the entire first-hatch tour so it can be tested again from scratch */
|
||||
resetTour: () => void;
|
||||
/** Current tour step id, or null if not active */
|
||||
currentStepId: string | null;
|
||||
/** Whether the tour has been completed */
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface BlobbiDevEditorProps {
|
||||
/** Whether the editor modal is open */
|
||||
isOpen: boolean;
|
||||
@@ -50,8 +38,6 @@ interface BlobbiDevEditorProps {
|
||||
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
|
||||
/** Whether an update is in progress */
|
||||
isUpdating?: boolean;
|
||||
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
|
||||
tourDevActions?: FirstHatchTourDevActions;
|
||||
}
|
||||
|
||||
/** Updates that can be applied to a Blobbi */
|
||||
@@ -184,7 +170,6 @@ export function BlobbiDevEditor({
|
||||
companion,
|
||||
onApply,
|
||||
isUpdating = false,
|
||||
tourDevActions,
|
||||
}: BlobbiDevEditorProps) {
|
||||
// ─── Local State ───
|
||||
// Initialize from companion values
|
||||
@@ -545,79 +530,7 @@ export function BlobbiDevEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── First-Hatch Tour Controls ─── */}
|
||||
{tourDevActions && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tourDevActions.isCompleted
|
||||
? 'Completed'
|
||||
: tourDevActions.currentStepId
|
||||
? tourDevActions.currentStepId
|
||||
: 'Not started'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Test the first-hatch tour flow without needing to create a real post.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* A. Skip Post Requirement */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.skipPostRequirement();
|
||||
}}
|
||||
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
|
||||
className="gap-2 text-xs"
|
||||
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
Skip Post
|
||||
</Button>
|
||||
|
||||
{/* B. Restart First-Hatch Tour */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
className="gap-2 text-xs"
|
||||
title="Reset the entire first-hatch tour state so it can be tested again"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Restart Tour
|
||||
</Button>
|
||||
|
||||
{/* C. Reset Blobbi to Egg */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStage('egg');
|
||||
setState('active');
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
disabled={companion.stage === 'egg'}
|
||||
className="gap-2 text-xs"
|
||||
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
|
||||
>
|
||||
<Egg className="size-3.5" />
|
||||
Reset to Egg + Tour
|
||||
</Button>
|
||||
</div>
|
||||
{companion.stage !== 'egg' && stage === 'egg' && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Theater } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEmotionDev } from './EmotionDevContext';
|
||||
import { useEmotionDev } from './useEmotionDev';
|
||||
import { isLocalhostDev } from './index';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
|
||||
|
||||
@@ -10,26 +10,10 @@
|
||||
* - Is purely for visual testing/debugging
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { useState, useCallback, type ReactNode } from 'react';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EmotionDevContextValue {
|
||||
/** Current dev emotion override (null = use default/neutral) */
|
||||
devEmotion: BlobbiEmotion | null;
|
||||
/** Set the dev emotion override */
|
||||
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
|
||||
/** Clear the dev emotion override (back to neutral) */
|
||||
clearDevEmotion: () => void;
|
||||
/** Whether dev emotion is active */
|
||||
isDevEmotionActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
|
||||
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,40 +52,4 @@ export function EmotionDevProvider({ children }: EmotionDevProviderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to access dev emotion state.
|
||||
* Returns null values in production for safety.
|
||||
*/
|
||||
export function useEmotionDev(): EmotionDevContextValue {
|
||||
const context = useContext(EmotionDevContext);
|
||||
|
||||
// Outside localhost dev or if no provider, return safe defaults
|
||||
if (!isLocalhostDev() || !context) {
|
||||
return {
|
||||
devEmotion: null,
|
||||
setDevEmotion: () => {},
|
||||
clearDevEmotion: () => {},
|
||||
isDevEmotionActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective emotion for a Blobbi.
|
||||
* In dev mode with an override, returns the dev emotion.
|
||||
* Otherwise returns the provided emotion or 'neutral'.
|
||||
*/
|
||||
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
|
||||
const { devEmotion, isDevEmotionActive } = useEmotionDev();
|
||||
|
||||
// Dev override takes precedence (only in localhost dev)
|
||||
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
|
||||
return devEmotion;
|
||||
}
|
||||
|
||||
return baseEmotion ?? 'neutral';
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
|
||||
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
|
||||
|
||||
// Emotion testing tools
|
||||
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
|
||||
export { EmotionDevProvider } from './EmotionDevContext';
|
||||
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
|
||||
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* IMPORTANT: This hook should only be used in development mode.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -24,8 +24,6 @@ interface UseBlobbiDevUpdateParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
interface DevUpdateResult {
|
||||
@@ -50,11 +48,9 @@ function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
export function useBlobbiDevUpdate({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiDevUpdateParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (updates: BlobbiDevUpdates): Promise<DevUpdateResult> => {
|
||||
@@ -169,12 +165,6 @@ export function useBlobbiDevUpdate({
|
||||
|
||||
// ─── Update Caches ───
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate collection queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
|
||||
return {
|
||||
previousStage: companion.stage,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmotionDevContextValue {
|
||||
/** Current dev emotion override (null = use default/neutral) */
|
||||
devEmotion: BlobbiEmotion | null;
|
||||
/** Set the dev emotion override */
|
||||
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
|
||||
/** Clear the dev emotion override (back to neutral) */
|
||||
clearDevEmotion: () => void;
|
||||
/** Whether dev emotion is active */
|
||||
isDevEmotionActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
|
||||
|
||||
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to access dev emotion state.
|
||||
* Returns null values in production for safety.
|
||||
*/
|
||||
export function useEmotionDev(): EmotionDevContextValue {
|
||||
const context = useContext(EmotionDevContext);
|
||||
|
||||
// Outside localhost dev or if no provider, return safe defaults
|
||||
if (!isLocalhostDev() || !context) {
|
||||
return {
|
||||
devEmotion: null,
|
||||
setDevEmotion: () => {},
|
||||
clearDevEmotion: () => {},
|
||||
isDevEmotionActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective emotion for a Blobbi.
|
||||
* In dev mode with an override, returns the dev emotion.
|
||||
* Otherwise returns the provided emotion or 'neutral'.
|
||||
*/
|
||||
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
|
||||
const { devEmotion, isDevEmotionActive } = useEmotionDev();
|
||||
|
||||
// Dev override takes precedence (only in localhost dev)
|
||||
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
|
||||
return devEmotion;
|
||||
}
|
||||
|
||||
return baseEmotion ?? 'neutral';
|
||||
}
|
||||
@@ -438,3 +438,354 @@
|
||||
filter: grayscale(1) contrast(1.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Onboarding Hatching Ceremony Animations
|
||||
========================================== */
|
||||
|
||||
/* Soft breathing pulse for the egg before interaction */
|
||||
@keyframes egg-onboard-breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: brightness(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015);
|
||||
filter: brightness(1.03) drop-shadow(0 0 30px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-breathe {
|
||||
animation: egg-onboard-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Screen-filling radial glow that expands from center on hatch */
|
||||
@keyframes onboard-glow-expand {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-expand {
|
||||
animation: onboard-glow-expand 1.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle lingering glow fade after hatch - holds then fades */
|
||||
@keyframes onboard-glow-linger {
|
||||
0% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-linger {
|
||||
animation: onboard-glow-linger 7s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Sentimental text fade in - very slow, dreamlike */
|
||||
@keyframes onboard-text-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-text-reveal {
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Delayed text reveal for secondary text */
|
||||
.animate-onboard-text-reveal-delay {
|
||||
opacity: 0;
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
/* Soft fade out for transition between phases */
|
||||
@keyframes onboard-soft-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-out {
|
||||
animation: onboard-soft-fade-out 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Soft fade in */
|
||||
@keyframes onboard-soft-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-in {
|
||||
animation: onboard-soft-fade-in 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Floating particles that drift upward from the egg */
|
||||
@keyframes onboard-particle-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.5);
|
||||
}
|
||||
20% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-120px) scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle twinkle - stays in place, pulses brightness */
|
||||
@keyframes onboard-sparkle-twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle drift - gentle floating motion */
|
||||
@keyframes onboard-sparkle-drift {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.3);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-8px) scale(1);
|
||||
}
|
||||
80% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-25px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px) scale(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Egg entrance - subtle float up from darkness */
|
||||
@keyframes egg-onboard-entrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-entrance {
|
||||
animation: egg-onboard-entrance 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Egg shake intensifying - for crack stages */
|
||||
@keyframes egg-onboard-shake-light {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
25% { transform: translateX(-3px) rotate(-2deg); }
|
||||
75% { transform: translateX(3px) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-medium {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
20% { transform: translateX(-5px) rotate(-3deg); }
|
||||
40% { transform: translateX(4px) rotate(2deg); }
|
||||
60% { transform: translateX(-4px) rotate(-2deg); }
|
||||
80% { transform: translateX(5px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-heavy {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
10% { transform: translateX(-6px) rotate(-4deg); }
|
||||
20% { transform: translateX(5px) rotate(3deg); }
|
||||
30% { transform: translateX(-7px) rotate(-3deg); }
|
||||
40% { transform: translateX(6px) rotate(4deg); }
|
||||
50% { transform: translateX(-5px) rotate(-2deg); }
|
||||
60% { transform: translateX(7px) rotate(3deg); }
|
||||
70% { transform: translateX(-6px) rotate(-4deg); }
|
||||
80% { transform: translateX(5px) rotate(2deg); }
|
||||
90% { transform: translateX(-4px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-light {
|
||||
animation: egg-onboard-shake-light 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-medium {
|
||||
animation: egg-onboard-shake-medium 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-heavy {
|
||||
animation: egg-onboard-shake-heavy 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* Final burst - egg explodes into light */
|
||||
@keyframes egg-onboard-burst {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
filter: brightness(2.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0;
|
||||
filter: brightness(4) blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-burst {
|
||||
animation: egg-onboard-burst 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Screen flash on hatch */
|
||||
@keyframes onboard-screen-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-screen-flash {
|
||||
animation: onboard-screen-flash 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle continue prompt pulse */
|
||||
@keyframes onboard-continue-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-continue-pulse {
|
||||
animation: onboard-continue-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slow rotating golden incandescence behind hatched blobbi */
|
||||
@keyframes onboard-golden-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(90deg) scale(1.06);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(270deg) scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-rotate {
|
||||
animation: onboard-golden-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Golden glow fade-in */
|
||||
@keyframes onboard-golden-fadein {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion overrides for onboarding */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-egg-onboard-breathe,
|
||||
.animate-onboard-glow-expand,
|
||||
.animate-onboard-glow-linger,
|
||||
.animate-onboard-text-reveal,
|
||||
.animate-onboard-text-reveal-delay,
|
||||
.animate-onboard-soft-fade-out,
|
||||
.animate-onboard-soft-fade-in,
|
||||
.animate-egg-onboard-entrance,
|
||||
.animate-egg-onboard-shake-light,
|
||||
.animate-egg-onboard-shake-medium,
|
||||
.animate-egg-onboard-shake-heavy,
|
||||
.animate-egg-onboard-burst,
|
||||
.animate-onboard-screen-flash,
|
||||
.animate-onboard-continue-pulse,
|
||||
.animate-onboard-golden-rotate,
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* BlobbiHatchingCeremony - Immersive hatching experience for every new egg
|
||||
*
|
||||
* Flow:
|
||||
* 1. Dark screen, egg silently created in background
|
||||
* 2. Huge breathing egg appears. No text. No UI.
|
||||
* 3. Click egg 4 times through crack stages with intensifying shakes
|
||||
* 4. Final click -> egg bursts into light, actual hatch mutation fires
|
||||
* 5. Flash clears -> hatched baby blobbi revealed center screen with glow/sparkles
|
||||
* 6. Typewriter dialog appears below blobbi (click to complete line / advance)
|
||||
* 7. Naming prompt, then ceremony complete
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
INITIAL_BLOBBONAUT_COINS,
|
||||
STAT_MAX,
|
||||
buildBlobbonautTags,
|
||||
updateBlobbonautTags,
|
||||
updateBlobbiTags,
|
||||
type BlobbonautProfile,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
generateEggPreview,
|
||||
previewToEventTags,
|
||||
previewToBlobbiCompanion,
|
||||
type BlobbiEggPreview,
|
||||
} from '../lib/blobbi-preview';
|
||||
|
||||
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BIRTH_DIALOG: string[] = [
|
||||
'Something stirs...',
|
||||
'A tiny life has chosen you. It knows only warmth, and your presence.',
|
||||
];
|
||||
|
||||
const NAMING_DIALOG = 'Every life deserves a name.\nWhat will you call this one?';
|
||||
|
||||
// ─── Phase Machine ────────────────────────────────────────────────────────────
|
||||
|
||||
type CeremonyPhase =
|
||||
| 'loading'
|
||||
| 'egg'
|
||||
| 'crack_1'
|
||||
| 'crack_2'
|
||||
| 'crack_3'
|
||||
| 'hatching' // egg burst + hatch mutation
|
||||
| 'reveal' // flash clearing, baby blobbi fading in with glow
|
||||
| 'dialog' // typewriter dialog lines
|
||||
| 'naming'
|
||||
| 'complete';
|
||||
|
||||
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
function useTypewriter(fullText: string, active: boolean, speed = 35) {
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const indexRef = useRef(0);
|
||||
|
||||
// Reset when text changes
|
||||
useEffect(() => {
|
||||
setDisplayed('');
|
||||
setDone(false);
|
||||
indexRef.current = 0;
|
||||
}, [fullText]);
|
||||
|
||||
// Run typewriter
|
||||
useEffect(() => {
|
||||
if (!active || done) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
indexRef.current++;
|
||||
const next = fullText.slice(0, indexRef.current);
|
||||
setDisplayed(next);
|
||||
if (indexRef.current >= fullText.length) {
|
||||
setDone(true);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
}, speed);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [active, done, fullText, speed]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
setDisplayed(fullText);
|
||||
setDone(true);
|
||||
}, [fullText]);
|
||||
|
||||
return { displayed, done, complete };
|
||||
}
|
||||
|
||||
// Module-level guard: prevents duplicate egg creation if the component remounts
|
||||
// (e.g. React strict mode, parent re-render causing unmount/remount).
|
||||
// Tracks pubkeys that have already started setup in this browser session.
|
||||
const setupInFlightFor = new Set<string>();
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiHatchingCeremonyProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
onComplete?: () => void;
|
||||
/** If provided, skip egg creation and start from the cracking phase with this existing egg. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/** If true, only create the egg and skip the hatching ceremony. The egg stays an egg. */
|
||||
eggOnly?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiHatchingCeremony({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
existingCompanion,
|
||||
eggOnly = false,
|
||||
}: BlobbiHatchingCeremonyProps) {
|
||||
const isExistingEgg = !!existingCompanion;
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: authorData } = useAuthor(user?.pubkey);
|
||||
|
||||
// ── Core state ──
|
||||
const [phase, setPhase] = useState<CeremonyPhase>('loading');
|
||||
const [preview, setPreview] = useState<BlobbiEggPreview | null>(null);
|
||||
const [name, setName] = useState(existingCompanion?.name ?? '');
|
||||
const [isNaming, setIsNaming] = useState(false);
|
||||
const [eggVisible, setEggVisible] = useState(false);
|
||||
|
||||
// Reveal phase state
|
||||
const [blobbiVisible, setBlobbiVisible] = useState(false);
|
||||
const [showFlash, setShowFlash] = useState(false);
|
||||
const [, setShowRevealGlow] = useState(false);
|
||||
const [fadeOut, setFadeOut] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [dialogLineIndex, setDialogLineIndex] = useState(0);
|
||||
const [dialogActive, setDialogActive] = useState(false);
|
||||
const [namingVisible, setNamingVisible] = useState(false);
|
||||
|
||||
// Refs
|
||||
const setupAttempted = useRef(false);
|
||||
const profileRef = useRef(profile);
|
||||
profileRef.current = profile;
|
||||
const previewRef = useRef(preview);
|
||||
previewRef.current = preview;
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const eggContainerRef = useRef<HTMLDivElement>(null);
|
||||
const entrancePlayed = useRef(false);
|
||||
const eggTagsRef = useRef<string[][] | null>(null);
|
||||
|
||||
// ── Companion visuals ──
|
||||
const eggCompanion = useMemo(
|
||||
() => preview ? previewToBlobbiCompanion(preview) : null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[preview?.d],
|
||||
);
|
||||
|
||||
// Baby companion (same visual data but stage=baby)
|
||||
const babyCompanion = useMemo((): BlobbiCompanion | null => {
|
||||
if (!eggCompanion) return null;
|
||||
return { ...eggCompanion, stage: 'baby', state: 'active' };
|
||||
}, [eggCompanion]);
|
||||
|
||||
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
|
||||
|
||||
// ── Typewriter for current dialog line ──
|
||||
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
|
||||
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
|
||||
|
||||
const namingTypewriter = useTypewriter(NAMING_DIALOG, namingVisible);
|
||||
|
||||
// ── Fast-path setup for existing eggs (no publishing needed) ──
|
||||
useEffect(() => {
|
||||
if (!isExistingEgg || setupAttempted.current || !existingCompanion) return;
|
||||
setupAttempted.current = true;
|
||||
|
||||
// Build a minimal preview from the existing companion
|
||||
const fakePreview: BlobbiEggPreview = {
|
||||
d: existingCompanion.d,
|
||||
petId: existingCompanion.d,
|
||||
ownerPubkey: user?.pubkey ?? '',
|
||||
name: existingCompanion.name,
|
||||
stage: 'egg',
|
||||
state: 'active',
|
||||
seed: existingCompanion.seed ?? '',
|
||||
stats: {
|
||||
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
|
||||
happiness: existingCompanion.stats.happiness ?? STAT_MAX,
|
||||
health: existingCompanion.stats.health ?? STAT_MAX,
|
||||
hygiene: existingCompanion.stats.hygiene ?? STAT_MAX,
|
||||
energy: existingCompanion.stats.energy ?? STAT_MAX,
|
||||
},
|
||||
visualTraits: existingCompanion.visualTraits,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
setPreview(fakePreview);
|
||||
previewRef.current = fakePreview;
|
||||
eggTagsRef.current = existingCompanion.allTags;
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isExistingEgg, existingCompanion?.d]);
|
||||
|
||||
// ── Silent setup: create profile + egg (new egg flow only) ──
|
||||
useEffect(() => {
|
||||
if (isExistingEgg) return; // Skip for existing eggs
|
||||
if (setupAttempted.current || !user?.pubkey) return;
|
||||
// Module-level guard: if another mount already started setup for this pubkey, skip
|
||||
if (setupInFlightFor.has(user.pubkey)) return;
|
||||
setupAttempted.current = true;
|
||||
setupInFlightFor.add(user.pubkey);
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const currentProfile = profileRef.current;
|
||||
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
|
||||
|
||||
// 1. Create profile if needed
|
||||
if (!currentProfile) {
|
||||
const suggestedName =
|
||||
authorData?.metadata?.display_name ||
|
||||
authorData?.metadata?.name ||
|
||||
'Blobbonaut';
|
||||
|
||||
const baseTags = buildBlobbonautTags(user.pubkey);
|
||||
const tagsWithName = [
|
||||
...baseTags,
|
||||
['name', suggestedName],
|
||||
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
|
||||
];
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: tagsWithName,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
invalidateProfile();
|
||||
latestProfileTags = tagsWithName;
|
||||
}
|
||||
|
||||
// 2. Generate and publish egg
|
||||
const eggPreview = generateEggPreview(user.pubkey, 'Egg');
|
||||
setPreview(eggPreview);
|
||||
previewRef.current = eggPreview;
|
||||
|
||||
const eggTags = previewToEventTags(eggPreview);
|
||||
eggTagsRef.current = eggTags;
|
||||
|
||||
const eggEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: 'A new Blobbi egg!',
|
||||
tags: eggTags,
|
||||
created_at: eggPreview.createdAt,
|
||||
});
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 3. Update profile with has[] entry
|
||||
if (latestProfileTags) {
|
||||
const existingHas = latestProfileTags
|
||||
.filter(([k]) => k === 'has')
|
||||
.map(([, v]) => v);
|
||||
const newHas = [...existingHas, eggPreview.d];
|
||||
|
||||
const updatedTags = updateBlobbonautTags(latestProfileTags, {
|
||||
has: newHas,
|
||||
});
|
||||
|
||||
const updatedProfileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(updatedProfileEvent);
|
||||
}
|
||||
|
||||
setStoredSelectedD(eggPreview.d);
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Setup failed:', error);
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Failed to set up your Blobbi. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
// Clear module-level guard so future adoptions can create new eggs
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(setup, 600);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// If the timer was cleared before setup ran, release the guard
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) profileRef.current = profile;
|
||||
}, [profile]);
|
||||
|
||||
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
|
||||
useEffect(() => {
|
||||
if (!eggOnly || !eggVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eggOnly, eggVisible, onComplete]);
|
||||
|
||||
// Play entrance animation once
|
||||
useEffect(() => {
|
||||
if (eggVisible && !entrancePlayed.current && eggContainerRef.current) {
|
||||
entrancePlayed.current = true;
|
||||
const el = eggContainerRef.current;
|
||||
el.classList.add('animate-egg-onboard-entrance');
|
||||
const onEnd = () => {
|
||||
el.classList.remove('animate-egg-onboard-entrance');
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
}
|
||||
}, [eggVisible]);
|
||||
|
||||
// ── Shake (DOM-only, no re-render) ──
|
||||
const triggerShake = useCallback((cls: string) => {
|
||||
const el = eggContainerRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove(
|
||||
'animate-egg-onboard-shake-light',
|
||||
'animate-egg-onboard-shake-medium',
|
||||
'animate-egg-onboard-shake-heavy',
|
||||
);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(cls);
|
||||
}, []);
|
||||
|
||||
// ── Execute the actual hatch: egg -> baby ──
|
||||
const executeHatch = useCallback(async () => {
|
||||
const tags = eggTagsRef.current;
|
||||
if (!tags) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const babyTags = updateBlobbiTags(tags, {
|
||||
stage: 'baby',
|
||||
state: 'active',
|
||||
hunger: STAT_MAX.toString(),
|
||||
happiness: STAT_MAX.toString(),
|
||||
health: STAT_MAX.toString(),
|
||||
hygiene: STAT_MAX.toString(),
|
||||
energy: STAT_MAX.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const babyName = previewRef.current?.name ?? 'Egg';
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${babyName} is a baby Blobbi.`,
|
||||
tags: babyTags,
|
||||
});
|
||||
|
||||
eggTagsRef.current = babyTags;
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
}, [publishEvent, updateCompanionEvent, invalidateCompanion]);
|
||||
|
||||
// ── Egg click ──
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (phase === 'egg') {
|
||||
triggerShake('animate-egg-onboard-shake-light');
|
||||
setPhase('crack_1');
|
||||
} else if (phase === 'crack_1') {
|
||||
triggerShake('animate-egg-onboard-shake-medium');
|
||||
setPhase('crack_2');
|
||||
} else if (phase === 'crack_2') {
|
||||
triggerShake('animate-egg-onboard-shake-heavy');
|
||||
setPhase('crack_3');
|
||||
} else if (phase === 'crack_3') {
|
||||
// Final click -> hatch!
|
||||
setPhase('hatching');
|
||||
setShowFlash(true);
|
||||
|
||||
// Fire the actual hatch mutation
|
||||
executeHatch().catch(console.error);
|
||||
|
||||
// After flash, reveal the baby
|
||||
setTimeout(() => {
|
||||
setShowFlash(false);
|
||||
setShowRevealGlow(true);
|
||||
setPhase('reveal');
|
||||
|
||||
// Fade in blobbi
|
||||
setTimeout(() => setBlobbiVisible(true), 400);
|
||||
|
||||
// After blobbi settles, start dialog
|
||||
setTimeout(() => {
|
||||
setPhase('dialog');
|
||||
setDialogLineIndex(0);
|
||||
setDialogActive(true);
|
||||
}, 2200);
|
||||
}, 1400);
|
||||
}
|
||||
}, [phase, triggerShake, executeHatch]);
|
||||
|
||||
// ── Dialog click: complete line or advance ──
|
||||
const handleDialogClick = useCallback(() => {
|
||||
if (phase !== 'dialog') return;
|
||||
|
||||
if (!dialogTypewriter.done) {
|
||||
// Complete the current line instantly
|
||||
dialogTypewriter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to next line
|
||||
const nextIndex = dialogLineIndex + 1;
|
||||
if (nextIndex < BIRTH_DIALOG.length) {
|
||||
setDialogActive(false);
|
||||
setDialogLineIndex(nextIndex);
|
||||
// Small pause before next line starts
|
||||
setTimeout(() => setDialogActive(true), 150);
|
||||
} else {
|
||||
// All lines done -> naming
|
||||
setDialogActive(false);
|
||||
setTimeout(() => {
|
||||
setPhase('naming');
|
||||
setTimeout(() => {
|
||||
setNamingVisible(true);
|
||||
setTimeout(() => nameInputRef.current?.focus(), 600);
|
||||
}, 200);
|
||||
}, 400);
|
||||
}
|
||||
}, [phase, dialogTypewriter, dialogLineIndex]);
|
||||
|
||||
// ── Complete ceremony ──
|
||||
const completeCeremony = useCallback(async (finalName: string) => {
|
||||
try {
|
||||
// Update egg/baby name if changed
|
||||
const currentTags = eggTagsRef.current;
|
||||
if (currentTags && finalName !== (previewRef.current?.name ?? 'Egg')) {
|
||||
const namedTags = updateBlobbiTags(currentTags, { name: finalName });
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${finalName} is a baby Blobbi.`,
|
||||
tags: namedTags,
|
||||
});
|
||||
updateCompanionEvent(event);
|
||||
}
|
||||
|
||||
// Mark onboarding done
|
||||
const currentProfile = profileRef.current;
|
||||
if (currentProfile) {
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Failed to persist completion:', error);
|
||||
}
|
||||
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
|
||||
|
||||
// ── Naming submit ──
|
||||
const handleNameSubmit = useCallback(async () => {
|
||||
if (isNaming || !name.trim()) return;
|
||||
setIsNaming(true);
|
||||
|
||||
try {
|
||||
await completeCeremony(name.trim());
|
||||
setNamingVisible(false);
|
||||
// Fade to white, then complete
|
||||
setTimeout(() => {
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Naming failed:', error);
|
||||
toast({
|
||||
title: 'Failed to save name',
|
||||
description: 'Your Blobbi was created, but the name could not be saved.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
} finally {
|
||||
setIsNaming(false);
|
||||
}
|
||||
}, [name, isNaming, completeCeremony, onComplete]);
|
||||
|
||||
// ── Tour visual state for EggGraphic crack rendering ──
|
||||
const tourVisualState = useMemo(() => {
|
||||
switch (phase) {
|
||||
case 'crack_1': return 'crack_stage_1' as const;
|
||||
case 'crack_2': return 'crack_stage_2' as const;
|
||||
case 'crack_3': return 'crack_stage_3' as const;
|
||||
case 'hatching': return 'opening' as const;
|
||||
default: return 'idle' as const;
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const isEggPhase = phase === 'egg' || phase === 'crack_1' || phase === 'crack_2' || phase === 'crack_3';
|
||||
const isHatching = phase === 'hatching';
|
||||
const showBaby = phase === 'reveal' || phase === 'dialog' || phase === 'naming';
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)' }}
|
||||
>
|
||||
<div
|
||||
className="absolute size-32 rounded-full opacity-20 animate-pulse"
|
||||
style={{ background: `radial-gradient(circle, ${eggColor}40 0%, transparent 70%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-hidden select-none"
|
||||
style={{
|
||||
background: showBaby
|
||||
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
|
||||
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
|
||||
transition: 'background 2s ease-out',
|
||||
}}
|
||||
onClick={phase === 'dialog' ? handleDialogClick : undefined}
|
||||
>
|
||||
{/* ── Ambient background glow (egg phase only) ── */}
|
||||
{!showBaby && (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity"
|
||||
style={{
|
||||
transitionDuration: '3000ms',
|
||||
background: `radial-gradient(ellipse at 50% 50%, ${eggColor}30 0%, transparent 60%)`,
|
||||
opacity: (isEggPhase || isHatching) ? 0.07 : 0.05,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Floating particles (egg phase) ── */}
|
||||
{isEggPhase && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 2 + (i % 3),
|
||||
height: 2 + (i % 3),
|
||||
left: `${20 + (i * 12) % 60}%`,
|
||||
bottom: '40%',
|
||||
backgroundColor: `${eggColor}40`,
|
||||
animation: `onboard-particle-rise ${4 + i * 0.7}s ease-out ${i * 0.8}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The Egg ── */}
|
||||
{(isEggPhase || isHatching) && eggCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
ref={eggContainerRef}
|
||||
className={cn(
|
||||
'cursor-pointer relative',
|
||||
eggVisible ? '' : 'opacity-0',
|
||||
eggVisible && isEggPhase && 'animate-egg-onboard-breathe',
|
||||
isHatching && 'animate-egg-onboard-burst',
|
||||
)}
|
||||
onClick={isEggPhase ? handleEggClick : undefined}
|
||||
>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-full blur-2xl transition-opacity duration-1000"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${eggColor}50 0%, transparent 70%)`,
|
||||
opacity: phase === 'crack_3' ? 0.5 : phase === 'crack_2' ? 0.35 : phase === 'crack_1' ? 0.25 : 0.15,
|
||||
}}
|
||||
/>
|
||||
<BlobbiStageVisual
|
||||
companion={eggCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-56 sm:size-64 md:size-72"
|
||||
tourVisualState={tourVisualState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Screen flash ── */}
|
||||
{showFlash && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white animate-onboard-screen-flash pointer-events-none"
|
||||
style={{ zIndex: 80 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Hatched baby blobbi with golden incandescence ── */}
|
||||
{showBaby && babyCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
style={{ paddingBottom: '18%' }}
|
||||
>
|
||||
{/* Rotating golden incandescence */}
|
||||
<div className={cn(
|
||||
'absolute animate-onboard-golden-fadein',
|
||||
blobbiVisible ? '' : 'opacity-0',
|
||||
)}>
|
||||
<div
|
||||
className="animate-onboard-golden-rotate"
|
||||
style={{
|
||||
width: 900,
|
||||
height: 900,
|
||||
background: `conic-gradient(
|
||||
from 0deg,
|
||||
rgba(255, 250, 230, 0.18) 0deg,
|
||||
rgba(255, 245, 210, 0.50) 50deg,
|
||||
rgba(255, 250, 235, 0.22) 100deg,
|
||||
rgba(255, 248, 220, 0.15) 150deg,
|
||||
rgba(255, 245, 210, 0.48) 210deg,
|
||||
rgba(255, 250, 230, 0.20) 270deg,
|
||||
rgba(255, 248, 220, 0.15) 320deg,
|
||||
rgba(255, 250, 230, 0.18) 360deg
|
||||
)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bright white-gold shine directly behind blobbi */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 320,
|
||||
height: 320,
|
||||
background: 'radial-gradient(circle, rgba(255,255,245,0.70) 0%, rgba(255,250,225,0.30) 40%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wider golden halo */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity [transition-duration:2000ms]',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 700,
|
||||
height: 700,
|
||||
background: 'radial-gradient(circle, rgba(255, 248, 210, 0.40) 0%, rgba(255, 240, 190, 0.18) 40%, transparent 65%)',
|
||||
filter: 'blur(15px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Sparkles everywhere ── */}
|
||||
|
||||
{/* Inner ring - bright twinkling sparkles */}
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const angle = (i / 20) * Math.PI * 2;
|
||||
const r = 80 + (i % 4) * 35;
|
||||
const size = 4 + (i % 3) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`inner-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 2 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.4) 40%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,130,1) 0%, rgba(255,220,80,0.3) 50%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer ring - larger, slower sparkles */}
|
||||
{Array.from({ length: 16 }).map((_, i) => {
|
||||
const angle = (i / 16) * Math.PI * 2 + 0.3;
|
||||
const r = 170 + (i % 3) * 50;
|
||||
const size = 5 + (i % 4) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`outer-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 3 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
|
||||
: 'radial-gradient(circle, rgba(255,235,120,0.85) 0%, transparent 60%)',
|
||||
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.25}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scattered wide-field sparkles */}
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 2.7 + 1.3) * 0.5 + 0.5) * 80 + 10;
|
||||
const y = (Math.cos(i * 3.1 + 0.7) * 0.5 + 0.5) * 70 + 10;
|
||||
const size = 3 + (i % 3) * 2;
|
||||
return (
|
||||
<div
|
||||
key={`field-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
borderRadius: '50%',
|
||||
background: i % 4 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.95) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,160,0.8) 0%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${2 + (i % 7) * 0.6}s ease-in-out ${i * 0.18}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drifting light motes rising from below */}
|
||||
{Array.from({ length: 10 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 1.9) * 0.5 + 0.5) * 70 + 15;
|
||||
return (
|
||||
<div
|
||||
key={`drift-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 5 + (i % 3) * 3,
|
||||
height: 5 + (i % 3) * 3,
|
||||
left: `${x}%`,
|
||||
bottom: '20%',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(255,250,200,0.9) 0%, rgba(255,230,120,0.3) 50%, transparent 100%)',
|
||||
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.5}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The baby blobbi */}
|
||||
<div className={cn(
|
||||
'relative transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}>
|
||||
<BlobbiStageVisual
|
||||
companion={babyCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Dialog text (no box, blur behind) ── */}
|
||||
{phase === 'dialog' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className="relative max-w-md w-full text-center">
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Speaker */}
|
||||
<div className="relative">
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter text */}
|
||||
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[3em]">
|
||||
{dialogTypewriter.displayed}
|
||||
{!dialogTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Advance indicator */}
|
||||
{dialogTypewriter.done && (
|
||||
<div className="mt-4 animate-onboard-continue-pulse">
|
||||
<span className="text-xs text-white/30">▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Naming ── */}
|
||||
{phase === 'naming' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className={cn(
|
||||
'relative max-w-md w-full text-center',
|
||||
namingVisible ? 'animate-onboard-soft-fade-in' : 'opacity-0',
|
||||
)}>
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Speaker */}
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter question */}
|
||||
<p className="text-base sm:text-lg text-white/85 leading-relaxed font-light mb-6 min-h-[1.5em] whitespace-pre-line">
|
||||
{namingTypewriter.displayed}
|
||||
{!namingTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Input + confirm (appear after typewriter done) */}
|
||||
{namingTypewriter.done && (
|
||||
<div className="space-y-3 animate-onboard-soft-fade-in">
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="..."
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
className={cn(
|
||||
'text-center text-lg font-light h-12',
|
||||
'bg-white/10 border-transparent text-white placeholder:text-white/30',
|
||||
'focus:bg-white/[0.25] focus:border-transparent focus:ring-0 focus:outline-none',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'focus:shadow-[0_0_15px_rgba(255,255,255,0.15),0_0_40px_rgba(255,250,230,0.08)]',
|
||||
'transition-all duration-300',
|
||||
'rounded-full transition-shadow duration-500',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) handleNameSubmit();
|
||||
}}
|
||||
/>
|
||||
|
||||
{name.trim() && (
|
||||
<Button
|
||||
onClick={handleNameSubmit}
|
||||
disabled={isNaming}
|
||||
className={cn(
|
||||
'max-w-[12rem] mx-auto h-10 px-8 text-sm font-light tracking-wide',
|
||||
'bg-white/15 hover:bg-white/22 text-white/80 border-transparent',
|
||||
'rounded-full transition-all duration-300',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
)}
|
||||
variant="ghost"
|
||||
>
|
||||
That's the one.
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fade to white on completion ── */}
|
||||
{fadeOut && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white pointer-events-none"
|
||||
style={{
|
||||
zIndex: 90,
|
||||
animation: 'blobbi-fade-to-white 2s ease-in forwards',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,19 @@
|
||||
/**
|
||||
* BlobbiOnboardingFlow - Main component that orchestrates the onboarding steps
|
||||
*
|
||||
* This component renders the appropriate onboarding step based on the user's
|
||||
* actual profile state. The initial step is derived from whether the profile
|
||||
* exists - not hardcoded.
|
||||
*
|
||||
* MODES:
|
||||
* 1. Full onboarding (default): Auto profile creation → Adoption question → Preview
|
||||
* 2. Adoption only (adoptionOnly=true): Skip directly to Preview for existing profiles
|
||||
*
|
||||
* IMPORTANT: This component should only be rendered when:
|
||||
* - User has no profile (auto-creates profile using kind 0 name)
|
||||
* - User has profile but no pets (shows adoption)
|
||||
* - User wants to adopt another Blobbi (adoptionOnly mode)
|
||||
*
|
||||
* Profile creation is now automatic - no manual name entry step is needed.
|
||||
* BlobbiOnboardingFlow - Immersive hatching ceremony for every new Blobbi
|
||||
*
|
||||
* Every new egg goes through the hatching ceremony - whether it's a user's
|
||||
* first Blobbi or their tenth. The ceremony creates the egg silently in the
|
||||
* background and presents a wordless, emotional hatching experience.
|
||||
*
|
||||
* The `adoptionOnly` prop is accepted for API compatibility but no longer
|
||||
* changes the flow - every egg gets the full ceremony.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useBlobbiOnboarding } from '../hooks/useBlobbiOnboarding';
|
||||
import { BlobbiAdoptionStep } from './BlobbiAdoptionStep';
|
||||
import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
|
||||
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
interface BlobbiOnboardingFlowProps {
|
||||
/** Current profile (null if doesn't exist) */
|
||||
@@ -43,9 +30,11 @@ interface BlobbiOnboardingFlowProps {
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
/** Called when onboarding is complete */
|
||||
onComplete?: () => void;
|
||||
/**
|
||||
* If true, skip profile creation and adoption question, go directly to preview.
|
||||
* Use this for "Adopt another Blobbi" flow for existing users.
|
||||
/** If provided, skip egg creation and use this existing egg for the ceremony. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/**
|
||||
* Accepted for API compatibility. Every new egg goes through the ceremony.
|
||||
* @deprecated No longer changes the flow.
|
||||
*/
|
||||
adoptionOnly?: boolean;
|
||||
}
|
||||
@@ -58,98 +47,20 @@ export function BlobbiOnboardingFlow({
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly = false,
|
||||
existingCompanion,
|
||||
adoptionOnly,
|
||||
}: BlobbiOnboardingFlowProps) {
|
||||
const [showAdoptConfirmDialog, setShowAdoptConfirmDialog] = useState(false);
|
||||
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
coins,
|
||||
} = useBlobbiOnboarding({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('[BlobbiOnboardingFlow] Rendering:', {
|
||||
hasProfile: !!profile,
|
||||
profileName: profile?.name,
|
||||
step: state.step,
|
||||
hasPreview: !!state.preview,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Handle adopt button click - show confirmation dialog
|
||||
const handleAdoptClick = () => {
|
||||
setShowAdoptConfirmDialog(true);
|
||||
};
|
||||
|
||||
// Handle confirm adoption
|
||||
const handleConfirmAdopt = async () => {
|
||||
await actions.adoptPreview();
|
||||
setShowAdoptConfirmDialog(false);
|
||||
};
|
||||
|
||||
// ─── Step: Auto Profile Creation ──────────────────────────────────────────────
|
||||
// Shows a loading state while profile is being auto-created
|
||||
if (state.step === 'creating-profile') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] gap-4 p-8">
|
||||
<Loader2 className="size-10 text-primary animate-spin" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
Setting up your profile...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Adoption Question ──────────────────────────────────────────────────
|
||||
// Shown when profile exists but user has no pets yet
|
||||
if (state.step === 'adoption-question') {
|
||||
return (
|
||||
<BlobbiAdoptionStep
|
||||
blobbonautName={state.blobbonautName || profile?.name}
|
||||
onStartAdoption={actions.startAdoptionPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Egg Preview ────────────────────────────────────────────────────────
|
||||
// Shown when user is previewing/choosing an egg to adopt
|
||||
if (state.step === 'preview' && state.preview) {
|
||||
return (
|
||||
<>
|
||||
<BlobbiEggPreviewCard
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isFirstPreview={state.isFirstPreview}
|
||||
isProcessing={state.isProcessing}
|
||||
actionInProgress={state.actionInProgress === 'reroll' ? 'reroll' : state.actionInProgress === 'adopt' ? 'adopt' : null}
|
||||
onReroll={actions.rerollPreview}
|
||||
onAdopt={handleAdoptClick}
|
||||
onNameChange={actions.setPreviewName}
|
||||
/>
|
||||
|
||||
<BlobbiAdoptionConfirmDialog
|
||||
open={showAdoptConfirmDialog}
|
||||
onOpenChange={setShowAdoptConfirmDialog}
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isAdopting={state.isProcessing && state.actionInProgress === 'adopt'}
|
||||
onConfirm={handleConfirmAdopt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen if parent logic is correct)
|
||||
console.warn('[BlobbiOnboardingFlow] Unexpected state - no matching step');
|
||||
return null;
|
||||
return (
|
||||
<BlobbiHatchingCeremony
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
updateCompanionEvent={updateCompanionEvent}
|
||||
invalidateProfile={invalidateProfile}
|
||||
invalidateCompanion={invalidateCompanion}
|
||||
setStoredSelectedD={setStoredSelectedD}
|
||||
onComplete={onComplete}
|
||||
existingCompanion={existingCompanion}
|
||||
eggOnly={adoptionOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Blobbi Onboarding Module
|
||||
*
|
||||
* Provides components and hooks for the Blobbi onboarding flow:
|
||||
* 1. Auto profile creation (using kind 0 name)
|
||||
* 2. Adoption question
|
||||
* 3. Egg preview with reroll/adopt
|
||||
*
|
||||
* Every new egg goes through the immersive hatching ceremony:
|
||||
* dark screen, huge egg, click-to-hatch, sentimental birth reveal, naming.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { BlobbiAdoptionStep } from './components/BlobbiAdoptionStep';
|
||||
export { BlobbiEggPreviewCard } from './components/BlobbiEggPreviewCard';
|
||||
export { BlobbiAdoptionConfirmDialog } from './components/BlobbiAdoptionConfirmDialog';
|
||||
export { BlobbiOnboardingFlow } from './components/BlobbiOnboardingFlow';
|
||||
export { BlobbiHatchingCeremony } from './components/BlobbiHatchingCeremony';
|
||||
|
||||
// Hooks
|
||||
// Hooks (used internally; kept exported for potential external use)
|
||||
export { useBlobbiOnboarding } from './hooks/useBlobbiOnboarding';
|
||||
export type {
|
||||
OnboardingStep,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Package, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -20,7 +18,7 @@ import {
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ItemEffectDisplay } from './ItemEffectDisplay';
|
||||
@@ -31,228 +29,169 @@ interface BlobbiInventoryModalProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
/** The current companion (needed for stage-based restrictions) */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called when user wants to use an item. Opens the use flow. */
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
/** Called when user wants to use an item. Always uses once. */
|
||||
onUseItem?: (itemId: string) => void;
|
||||
/** Whether an item is currently being used */
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
/** Resolved inventory item with shop metadata and usability info */
|
||||
/** Resolved catalog item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
|
||||
// ── Shared items content (used by both standalone modal and unified shop modal) ──
|
||||
|
||||
interface BlobbiInventoryContentProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
onUseItem?: (itemId: string) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiInventoryContent({
|
||||
profile,
|
||||
profile: _profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
}: BlobbiInventoryContentProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showUseDialog, setShowUseDialog] = useState(false);
|
||||
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
const isEmpty = inventoryItems.length === 0;
|
||||
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
setQuantity(1);
|
||||
setShowUseDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || !onUseItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleCloseUseDialog = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else {
|
||||
setQuantity(Math.min(value, maxQuantity));
|
||||
}
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
|
||||
</p>
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for your Blobbi's current stage.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Badge */}
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSelectItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use Item Confirmation Dialog */}
|
||||
{selectedItem && companion && (
|
||||
<InventoryUseConfirmDialog
|
||||
open={showUseDialog}
|
||||
onOpenChange={handleCloseUseDialog}
|
||||
item={selectedItem}
|
||||
companion={companion}
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onQuantityChange={handleQuantityInput}
|
||||
onConfirm={handleConfirmUse}
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleUseItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isUsingItem ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,153 +237,3 @@ export function BlobbiInventoryModal({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
|
||||
|
||||
interface InventoryUseConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onConfirm: () => void;
|
||||
isUsing: boolean;
|
||||
}
|
||||
|
||||
function InventoryUseConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
companion,
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: InventoryUseConfirmDialogProps) {
|
||||
const totalEffect = useMemo(() => {
|
||||
if (!item.effect) return null;
|
||||
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
if (delta !== undefined) {
|
||||
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
if (delta !== 0) {
|
||||
result[stat] = delta;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}, [item.effect, companion.stats, quantity]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Use Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Item Preview */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onDecrease}
|
||||
disabled={quantity <= 1 || isUsing}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
value={quantity}
|
||||
onChange={onQuantityChange}
|
||||
disabled={isUsing}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onIncrease}
|
||||
disabled={quantity >= maxQuantity || isUsing}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{totalEffect && (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
|
||||
</h4>
|
||||
<ItemEffectDisplay effect={totalEffect} variant="badges" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUsing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isUsing}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isUsing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Using...
|
||||
</>
|
||||
) : (
|
||||
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,16 @@ import {
|
||||
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
type TopTab = 'items' | 'shop';
|
||||
|
||||
/** Resolved inventory item with shop metadata and usability info */
|
||||
/** Resolved catalog item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
@@ -39,7 +38,7 @@ interface BlobbiShopModalProps {
|
||||
initialTab?: TopTab;
|
||||
// ── Inventory props (passed through) ──
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
onUseItem?: (itemId: string) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
@@ -80,28 +79,24 @@ export function BlobbiShopModal({
|
||||
|
||||
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
|
||||
|
||||
// ── Inventory items resolution ──
|
||||
// ── Items resolution — sourced from the full catalog (not inventory) ──
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allCatalogItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allCatalogItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
// ── Inventory use item handler ──
|
||||
const [usingItemId, setUsingItemId] = useState<string | null>(null);
|
||||
@@ -109,7 +104,7 @@ export function BlobbiShopModal({
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
setUsingItemId(item.itemId);
|
||||
onUseItem(item.itemId, 1);
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
// Clear usingItemId when isUsingItem goes false
|
||||
@@ -138,7 +133,7 @@ export function BlobbiShopModal({
|
||||
Items
|
||||
{!inventoryEmpty && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
|
||||
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
|
||||
{inventoryItems.length}
|
||||
</Badge>
|
||||
)}
|
||||
{topTab === 'items' && (
|
||||
@@ -265,7 +260,7 @@ function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridPr
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
|
||||
// ─── Items Grid (catalog, tile layout) ────────────────────────────────────────
|
||||
|
||||
interface ItemsGridProps {
|
||||
items: ResolvedInventoryItem[];
|
||||
@@ -275,20 +270,16 @@ interface ItemsGridProps {
|
||||
onGoToShop: () => void;
|
||||
}
|
||||
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No items yet. Visit the shop to stock up!
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No items are available for your Blobbi's current stage.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
|
||||
<ShoppingBag className="size-3.5" />
|
||||
Browse Shop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,13 +299,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
|
||||
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Quantity badge */}
|
||||
<Badge
|
||||
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
|
||||
>
|
||||
{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Used by:
|
||||
* - BlobbiShopItemRow (shop listing)
|
||||
* - BlobbiPurchaseDialog (purchase confirmation)
|
||||
* - BlobbiInventoryModal (inventory listing)
|
||||
* - BlobbiInventoryModal (items listing)
|
||||
* - BlobbiActionInventoryModal (action item selection)
|
||||
*
|
||||
* All consumers should use this component to ensure consistent display of item effects.
|
||||
@@ -192,30 +192,6 @@ export function ItemEffectDisplay({
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Utility Exports ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format effects as a summary string (for compatibility with existing code).
|
||||
* This is a drop-in replacement for formatEffectSummary in blobbi-shop-utils.ts.
|
||||
*
|
||||
* @deprecated Use <ItemEffectDisplay variant="inline" /> instead
|
||||
*/
|
||||
export function formatEffectSummary(effect: ItemEffect | undefined, maxEffects = 4): string {
|
||||
const entries = getSortedEffectEntries(effect);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return 'No effects';
|
||||
}
|
||||
|
||||
const displayEntries = maxEffects !== undefined ? entries.slice(0, maxEffects) : entries;
|
||||
|
||||
return displayEntries
|
||||
.map(([stat, value]) => `${formatStatValue(value)} ${STAT_LABELS[stat]}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted effect entries for custom rendering.
|
||||
* Useful when you need to iterate over effects yourself.
|
||||
*/
|
||||
export { getSortedEffectEntries };
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
|
||||
*
|
||||
* Rendered directly in the BlobbiPage layout so the experience feels
|
||||
* focused and guided. Adapts its messaging based on the current tour step.
|
||||
*
|
||||
* When the post mission is completed, the card stays visible with a
|
||||
* celebratory completed state for ~2s (the parent auto-advances after
|
||||
* that delay). This ensures the user sees the checkmark before the
|
||||
* flow progresses to the egg-tap phase.
|
||||
*/
|
||||
|
||||
import { Send, Check, MousePointerClick } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { FirstHatchTourStepId } from '../lib/tour-types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourCardProps {
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Current tour step id for adaptive messaging */
|
||||
currentStep: FirstHatchTourStepId | null;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourCard({
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
currentStep,
|
||||
}: FirstHatchTourCardProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
// Determine which phase of the card to show
|
||||
const isPostStep = currentStep === 'show_hatch_card';
|
||||
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|
||||
|| currentStep === 'egg_crack_stage_1'
|
||||
|| currentStep === 'egg_crack_stage_2'
|
||||
|| currentStep === 'egg_crack_stage_3';
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm mx-auto space-y-4">
|
||||
{/* Title + description */}
|
||||
<div className="text-center space-y-1.5">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{isClickStep
|
||||
? `Tap ${capitalizedName} to hatch!`
|
||||
: postCompleted && isPostStep
|
||||
? `${capitalizedName} heard you!`
|
||||
: `${capitalizedName} is ready to hatch!`}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{isClickStep
|
||||
? `Tap the egg to help ${capitalizedName} break free.`
|
||||
: postCompleted && isPostStep
|
||||
? 'Your post was shared. Get ready to hatch...'
|
||||
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card - only during post step */}
|
||||
{isPostStep && (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
{postCompleted ? (
|
||||
/* ── Completed state — celebratory, stays visible ── */
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
|
||||
<Check className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
Post shared!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Continuing in a moment...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Pending state — post mission ── */
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">Share a hatch post</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your post must include:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tap hint during click steps */}
|
||||
{isClickStep && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MousePointerClick className="size-4" />
|
||||
<span>Tap the egg</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra hint for post step */}
|
||||
{isPostStep && !postCompleted && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
|
||||
*
|
||||
* Tells the user their egg is about to hatch and guides them to create a post.
|
||||
* Contains a single mission: create the hatch post.
|
||||
*/
|
||||
|
||||
import { Egg, Send, Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Advance the tour (called after post is confirmed complete) */
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
onContinue,
|
||||
}: FirstHatchTourModalProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header with egg accent */}
|
||||
<div className="px-6 pt-8 pb-4 text-center space-y-3">
|
||||
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<Egg className="size-7 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{capitalizedName} is ready to hatch!
|
||||
</DialogTitle>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Share a post to the Nostr network and help {capitalizedName} break free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className={
|
||||
postCompleted
|
||||
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
|
||||
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
|
||||
}>
|
||||
{postCompleted && <Check className="size-3 text-emerald-500" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Post must include the phrase:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!postCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6">
|
||||
{postCompleted ? (
|
||||
<Button className="w-full" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
|
||||
*
|
||||
* Orchestration only -- no rendering, no animations.
|
||||
* The hook manages:
|
||||
* - Ordered step progression
|
||||
* - Persisted state via localStorage (survives refresh / close)
|
||||
* - Derived booleans for UI consumption
|
||||
* - Safe advance / goTo / complete / reset actions
|
||||
*
|
||||
* Activation is handled separately by useFirstHatchTourActivation,
|
||||
* which calls `start()` when all preconditions are met.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Future integration points
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
|
||||
* to decide whether to start the tour.
|
||||
* 2. UI components read `state.currentStepId` and render overlays,
|
||||
* spotlights, modals, or animation cues accordingly.
|
||||
* 3. Animation components call `actions.advance()` when their
|
||||
* sequence finishes (for autoAdvance steps).
|
||||
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
|
||||
* on the user interaction.
|
||||
* 5. EggGraphic receives a visual-state prop derived from
|
||||
* `state.currentStepId` -- it does NOT own the tour logic.
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
type FirstHatchTourStepId,
|
||||
type FirstHatchTourPersistedState,
|
||||
type TourState,
|
||||
type TourActions,
|
||||
} from '../lib/tour-types';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* localStorage key for the first hatch tour state.
|
||||
* Not user-scoped because onboarding state is device-local and the tour
|
||||
* is inherently tied to "first ever egg on this device". If multi-user
|
||||
* support on the same device becomes a concern, scope by pubkey.
|
||||
*/
|
||||
const STORAGE_KEY = 'blobbi:tour:first-hatch';
|
||||
|
||||
/** Pre-computed lookup: stepId -> index */
|
||||
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
|
||||
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
|
||||
);
|
||||
|
||||
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
|
||||
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
|
||||
|
||||
// ─── Result Type ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseFirstHatchTourResult {
|
||||
/** Reactive tour state for UI consumption */
|
||||
state: TourState<FirstHatchTourStepId>;
|
||||
/** Actions to drive the tour forward */
|
||||
actions: TourActions<FirstHatchTourStepId>;
|
||||
/**
|
||||
* Convenience: check if the current step matches a given id.
|
||||
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
|
||||
*/
|
||||
isStep: (stepId: FirstHatchTourStepId) => boolean;
|
||||
/**
|
||||
* Convenience: check if the current step is one of the given ids.
|
||||
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
|
||||
*/
|
||||
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
|
||||
/**
|
||||
* The current step definition (with autoAdvance metadata), or null.
|
||||
*/
|
||||
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFirstHatchTour(): UseFirstHatchTourResult {
|
||||
// ── Persisted state ──
|
||||
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
|
||||
STORAGE_KEY,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
);
|
||||
|
||||
// Stable ref to current persisted state so callbacks never go stale.
|
||||
const persistedRef = useRef(persisted);
|
||||
persistedRef.current = persisted;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const updatePersisted = useCallback(
|
||||
(patch: Partial<FirstHatchTourPersistedState>) => {
|
||||
setPersisted((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
},
|
||||
[setPersisted],
|
||||
);
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const start = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
// No-op if already active or completed
|
||||
if (p.completed || p.currentStepId !== null) return;
|
||||
|
||||
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
|
||||
if (!firstStep) return;
|
||||
|
||||
updatePersisted({ currentStepId: firstStep.id });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
if (p.completed || p.currentStepId === null) return;
|
||||
|
||||
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
|
||||
if (currentIndex === undefined) return;
|
||||
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
|
||||
// Past the end -- complete
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
|
||||
if (nextStep.id === 'complete') {
|
||||
// Reaching the 'complete' terminal step means the tour is done
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: nextStep.id });
|
||||
}
|
||||
}, [updatePersisted]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => {
|
||||
if (!STEP_INDEX_MAP.has(stepId)) {
|
||||
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
|
||||
}
|
||||
|
||||
if (stepId === 'complete') {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: stepId, completed: false });
|
||||
}
|
||||
},
|
||||
[updatePersisted],
|
||||
);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
|
||||
}, [setPersisted]);
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
const currentStepIndex = persisted.currentStepId !== null
|
||||
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
|
||||
: -1;
|
||||
|
||||
const state = useMemo((): TourState<FirstHatchTourStepId> => {
|
||||
const isActive = persisted.currentStepId !== null && !persisted.completed;
|
||||
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
|
||||
|
||||
return {
|
||||
isActive,
|
||||
currentStepId: persisted.currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
|
||||
isCompleted: persisted.completed,
|
||||
progress: persisted.completed
|
||||
? 1
|
||||
: currentStepIndex >= 0
|
||||
? currentStepIndex / LAST_REAL_STEP_INDEX
|
||||
: 0,
|
||||
};
|
||||
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
|
||||
|
||||
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
|
||||
start,
|
||||
advance,
|
||||
goTo,
|
||||
complete,
|
||||
reset,
|
||||
}), [start, advance, goTo, complete, reset]);
|
||||
|
||||
// ── Convenience helpers ──
|
||||
|
||||
const isStep = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const isAnyStep = useCallback(
|
||||
(...stepIds: FirstHatchTourStepId[]) => {
|
||||
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
|
||||
},
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const currentStepDef = currentStepIndex >= 0
|
||||
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
|
||||
: null;
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
isStep,
|
||||
isAnyStep,
|
||||
currentStepDef,
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
|
||||
*
|
||||
* This hook checks all preconditions and calls `tour.actions.start()` when
|
||||
* the tour should activate. It is intentionally separated from the tour
|
||||
* state machine so that:
|
||||
* - The state machine stays generic and reusable.
|
||||
* - Activation rules are centralized in one place.
|
||||
* - The rules are easy to read and modify.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Activation rules (ALL must be true):
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. The companions list is loaded (not loading / error).
|
||||
* 2. The user has exactly 1 Blobbi.
|
||||
* 3. That Blobbi is in the egg stage.
|
||||
* 4. No Blobbi is in baby or adult stage.
|
||||
* 5. The tour has not been completed yet (checked via profile tag
|
||||
* AND localStorage fallback).
|
||||
*
|
||||
* Completion is authoritative from the Blobbonaut profile event
|
||||
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
|
||||
* is a secondary signal for in-progress UI state and as a fallback
|
||||
* when the profile hasn't been updated yet.
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirstHatchTourActivationInput {
|
||||
/** The full list of the user's Blobbi companions */
|
||||
companions: BlobbiCompanion[];
|
||||
/** Whether the companions list is still loading */
|
||||
isLoading: boolean;
|
||||
/** The tour hook result (localStorage-based state machine) */
|
||||
tour: UseFirstHatchTourResult;
|
||||
/**
|
||||
* Whether onboarding is already marked complete in the Blobbonaut profile
|
||||
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
|
||||
* When true, the tour will not activate regardless of localStorage state.
|
||||
*/
|
||||
profileOnboardingDone?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstHatchTourActivationResult {
|
||||
/**
|
||||
* Whether all preconditions for activating the tour are met right now.
|
||||
* This is a derived boolean -- it does NOT mean the tour IS active,
|
||||
* just that it SHOULD be activated. The tour may already be active
|
||||
* from a previous render or a persisted state.
|
||||
*/
|
||||
shouldActivate: boolean;
|
||||
/**
|
||||
* Whether the tour is eligible (preconditions met and not yet completed).
|
||||
* Useful for hiding UI that should only appear during the tour window.
|
||||
*/
|
||||
isEligible: boolean;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluates activation preconditions and auto-starts the tour when met.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const tour = useFirstHatchTour();
|
||||
* const activation = useFirstHatchTourActivation({
|
||||
* companions,
|
||||
* isLoading: companionsLoading,
|
||||
* tour,
|
||||
* profileOnboardingDone: profile?.onboardingDone,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading,
|
||||
tour,
|
||||
profileOnboardingDone: _profileOnboardingDone = false,
|
||||
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
|
||||
// ── Precondition evaluation ──
|
||||
|
||||
const { shouldActivate, isEligible } = useMemo(() => {
|
||||
// Can't evaluate until data is loaded
|
||||
if (isLoading) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// localStorage tour already completed — this is always authoritative
|
||||
if (tour.state.isCompleted) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// Must have exactly 1 companion
|
||||
if (companions.length !== 1) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
const onlyBlobbi = companions[0];
|
||||
|
||||
// That companion must be an egg
|
||||
if (onlyBlobbi.stage !== 'egg') {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
|
||||
// but kept explicit for clarity and future-proofing if rules change)
|
||||
const hasBabyOrAdult = companions.some(
|
||||
(c) => c.stage === 'baby' || c.stage === 'adult',
|
||||
);
|
||||
if (hasBabyOrAdult) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
|
||||
// Some older accounts had `onboarding_done` migrated to
|
||||
// `blobbi_onboarding_done=true` before the first-hatch tour
|
||||
// existed, so they never experienced it. When the user is in the
|
||||
// exact single-egg/no-evolved-companions state (all checks above
|
||||
// passed), we intentionally ignore `profileOnboardingDone` so
|
||||
// those accounts can still enter the tour.
|
||||
//
|
||||
// This is safe because:
|
||||
// - The localStorage `tour.state.isCompleted` check above
|
||||
// already prevents re-triggering for users who HAVE finished
|
||||
// the tour.
|
||||
// - The egg-stage + single-companion guard means this only
|
||||
// fires for users who genuinely haven't hatched yet.
|
||||
//
|
||||
// TODO: Replace `blobbi_onboarding_done` with a dedicated
|
||||
// `blobbi_first_hatch_tour_done` tag so onboarding completion
|
||||
// and tour completion are tracked independently. Once that tag
|
||||
// is in place, remove this safeguard and gate activation on the
|
||||
// new tag instead.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// (profileOnboardingDone is intentionally NOT checked here)
|
||||
|
||||
// All preconditions met
|
||||
const eligible = true;
|
||||
// Only activate if the tour is not already running
|
||||
const activate = !tour.state.isActive;
|
||||
|
||||
return { shouldActivate: activate, isEligible: eligible };
|
||||
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
|
||||
|
||||
// ── Auto-start effect ──
|
||||
// When all preconditions are met and the tour hasn't started yet,
|
||||
// start it. This fires once and then `shouldActivate` flips to false
|
||||
// because `tour.state.isActive` becomes true.
|
||||
useEffect(() => {
|
||||
if (shouldActivate) {
|
||||
tour.actions.start();
|
||||
}
|
||||
}, [shouldActivate, tour.actions]);
|
||||
|
||||
return { shouldActivate, isEligible };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Blobbi Tour Module
|
||||
*
|
||||
* Provides the orchestration layer for guided tours / tutorials.
|
||||
* Currently implements the first-egg hatch tour.
|
||||
*
|
||||
* Architecture:
|
||||
* - tour-types.ts: Step definitions, persisted state shape, generic types
|
||||
* - useFirstHatchTour: State machine (step progression, persistence, actions)
|
||||
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
|
||||
*
|
||||
* UI components import from this barrel and read tour state to decide
|
||||
* what to render. They call tour actions (advance, goTo, complete) in
|
||||
* response to user interactions or animation completions.
|
||||
*/
|
||||
|
||||
// ── Types (generic tour infrastructure) ──
|
||||
export type {
|
||||
TourStepDef,
|
||||
TourPersistedState,
|
||||
TourState,
|
||||
TourActions,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Types & Constants ──
|
||||
export {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
} from './lib/tour-types';
|
||||
export type {
|
||||
FirstHatchTourStepId,
|
||||
FirstHatchTourPersistedState,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Hooks ──
|
||||
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
|
||||
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
|
||||
|
||||
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
|
||||
export type {
|
||||
FirstHatchTourActivationInput,
|
||||
FirstHatchTourActivationResult,
|
||||
} from './hooks/useFirstHatchTourActivation';
|
||||
|
||||
// ── First Hatch Tour - Components ──
|
||||
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Tour System - Core Types
|
||||
*
|
||||
* Generic, reusable types for step-based guided tours.
|
||||
* The tour system is designed to be:
|
||||
* - Easy to extend with new tours (define steps + config)
|
||||
* - Easy to reorder steps (change the STEPS array)
|
||||
* - Persistent across page refreshes (localStorage)
|
||||
* - Decoupled from rendering (UI reads state, doesn't own it)
|
||||
*/
|
||||
|
||||
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A tour step definition.
|
||||
*
|
||||
* Each step has a unique id and optional metadata that future UI layers
|
||||
* can use to decide what to render (spotlights, modals, animations, etc.).
|
||||
*/
|
||||
export interface TourStepDef<StepId extends string = string> {
|
||||
/** Unique identifier for this step */
|
||||
id: StepId;
|
||||
/**
|
||||
* Whether this step auto-advances (e.g. animations) or waits for
|
||||
* an explicit `advance()` / `goTo()` call from the UI.
|
||||
* Default: false (manual).
|
||||
*/
|
||||
autoAdvance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted state for a tour.
|
||||
* Stored in localStorage so tours survive refresh / close / return.
|
||||
*/
|
||||
export interface TourPersistedState<StepId extends string = string> {
|
||||
/** Current step id, or null when the tour is not yet started */
|
||||
currentStepId: StepId | null;
|
||||
/** Whether the tour has been completed */
|
||||
completed: boolean;
|
||||
/** Unix ms timestamp of last state change (for debugging / analytics) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full runtime state exposed by a tour hook.
|
||||
*/
|
||||
export interface TourState<StepId extends string = string> {
|
||||
/** Whether the tour is currently active (started and not yet completed) */
|
||||
isActive: boolean;
|
||||
/** Current step id, or null when idle / completed */
|
||||
currentStepId: StepId | null;
|
||||
/** 0-based index of the current step in the steps array, or -1 */
|
||||
currentStepIndex: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Whether the current step is the last one before completion */
|
||||
isLastStep: boolean;
|
||||
/** Whether the tour has been completed (persisted) */
|
||||
isCompleted: boolean;
|
||||
/** Progress as a fraction 0..1 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions exposed by a tour hook.
|
||||
*/
|
||||
export interface TourActions<StepId extends string = string> {
|
||||
/** Start the tour from the first step (no-op if already active or completed) */
|
||||
start: () => void;
|
||||
/** Advance to the next step. Completes the tour if on the last step. */
|
||||
advance: () => void;
|
||||
/** Jump to a specific step by id. Throws if the step doesn't exist. */
|
||||
goTo: (stepId: StepId) => void;
|
||||
/** Mark the tour as completed and reset to idle. */
|
||||
complete: () => void;
|
||||
/** Reset the tour entirely (clears persisted state). For dev/testing. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step ids for the first-egg hatch tour.
|
||||
*
|
||||
* Flow:
|
||||
* 1. idle — initial state (auto-advances immediately)
|
||||
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
|
||||
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
|
||||
* 4. egg_crack_stage_1 — click 1: crack expands
|
||||
* 5. egg_crack_stage_2 — click 2: crack expands further
|
||||
* 6. egg_crack_stage_3 — click 3: crack reaches edges
|
||||
* 7. egg_opening — shell opens (auto-advance after animation)
|
||||
* 8. egg_hatching — bright light + baby reveal (auto-advance)
|
||||
* 9. complete — terminal, marks tour done
|
||||
*
|
||||
* The order here matches the intended flow. To reorder steps,
|
||||
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
|
||||
*/
|
||||
export type FirstHatchTourStepId =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'egg_glowing_waiting_click'
|
||||
| 'egg_crack_stage_1'
|
||||
| 'egg_crack_stage_2'
|
||||
| 'egg_crack_stage_3'
|
||||
| 'egg_opening'
|
||||
| 'egg_hatching'
|
||||
| 'complete';
|
||||
|
||||
/**
|
||||
* Ordered step definitions for the first hatch tour.
|
||||
*
|
||||
* To add / remove / reorder steps, edit this array.
|
||||
* The tour state machine walks through these in order.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
|
||||
{ id: 'idle' },
|
||||
{ id: 'show_hatch_card' },
|
||||
{ id: 'egg_glowing_waiting_click' },
|
||||
{ id: 'egg_crack_stage_1' },
|
||||
{ id: 'egg_crack_stage_2' },
|
||||
{ id: 'egg_crack_stage_3' },
|
||||
{ id: 'egg_opening', autoAdvance: true },
|
||||
{ id: 'egg_hatching', autoAdvance: true },
|
||||
{ id: 'complete' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Persisted state shape for the first hatch tour.
|
||||
*/
|
||||
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
|
||||
|
||||
/**
|
||||
* Default persisted state for a brand-new first hatch tour.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
|
||||
currentStepId: null,
|
||||
completed: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
+102
-178
@@ -1,50 +1,31 @@
|
||||
/**
|
||||
* BlobbiPhotoModal - Modal for taking and sharing Blobbi photos
|
||||
* BlobbiPhotoModal - Fullscreen photo overlay
|
||||
*
|
||||
* Features:
|
||||
* - Polaroid-style preview of the Blobbi
|
||||
* - Download as PNG
|
||||
* - Post to Nostr with Blossom upload
|
||||
*
|
||||
* Uses html-to-image for DOM-to-PNG conversion.
|
||||
* Simple blurred overlay with the polaroid photo centered,
|
||||
* and download/share buttons below. Tap outside to close.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Download, Send, Loader2, Camera } from 'lucide-react';
|
||||
import { Download, Share2, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BlobbiPolaroidCard } from './BlobbiPolaroidCard';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { trackDailyMissionProgress } from '@/blobbi/actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export interface BlobbiPhotoModalProps {
|
||||
/** Whether the modal is open */
|
||||
open: boolean;
|
||||
/** Callback when the modal should close */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi companion to photograph */
|
||||
companion: BlobbiCompanion;
|
||||
}
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a data URL to a File object
|
||||
*/
|
||||
function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png';
|
||||
@@ -57,218 +38,161 @@ function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
return new File([u8arr], filename, { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download in the browser
|
||||
*/
|
||||
function downloadFile(dataUrl: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPhotoModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
}: BlobbiPhotoModalProps) {
|
||||
const polaroidRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Generate PNG from the polaroid card
|
||||
*/
|
||||
const generateImage = useCallback(async (): Promise<string | null> => {
|
||||
if (!polaroidRef.current) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Could not capture the photo. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!polaroidRef.current) return null;
|
||||
try {
|
||||
// Use html-to-image with high quality settings
|
||||
const dataUrl = await toPng(polaroidRef.current, {
|
||||
return await toPng(polaroidRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // 2x for retina displays
|
||||
pixelRatio: 2,
|
||||
cacheBust: true,
|
||||
// Skip external fonts that might fail to load
|
||||
skipFonts: true,
|
||||
});
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to generate image:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to generate the photo. Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to generate image:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Failed to capture photo.' });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle download action
|
||||
*/
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const dataUrl = await generateImage();
|
||||
if (dataUrl) {
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`;
|
||||
downloadFile(dataUrl, filename);
|
||||
toast({
|
||||
title: 'Photo saved!',
|
||||
description: 'Your Blobbi photo has been downloaded.',
|
||||
});
|
||||
if (!dataUrl) return;
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-photo.png`;
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// On native, use the download utility which handles share sheet
|
||||
const blob = dataUrlToFile(dataUrl, filename);
|
||||
const url = URL.createObjectURL(blob);
|
||||
await openUrl(url);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
toast({ title: 'Photo saved!' });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [generateImage, companion.name]);
|
||||
|
||||
/**
|
||||
* Handle post action - upload to Blossom and create Nostr post
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to post your Blobbi photo.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPosting(true);
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
// Generate the image
|
||||
const dataUrl = await generateImage();
|
||||
if (!dataUrl) {
|
||||
return;
|
||||
}
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Convert to File for upload
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`;
|
||||
const file = dataUrlToFile(dataUrl, filename);
|
||||
|
||||
// Upload to Blossom - returns NIP-94 compatible tags
|
||||
const tags = await uploadFile(file);
|
||||
|
||||
// Extract URL from the 'url' tag (NIP-94 format)
|
||||
// The upload hook returns tags like [['url', '...'], ['m', '...'], ['x', '...'], ...]
|
||||
const urlTag = tags.find((tag) => tag[0] === 'url');
|
||||
if (!urlTag || !urlTag[1]) {
|
||||
throw new Error('Upload succeeded but no URL was returned');
|
||||
}
|
||||
if (!urlTag?.[1]) throw new Error('Upload succeeded but no URL returned');
|
||||
const url = urlTag[1];
|
||||
|
||||
// Build imeta tag from all NIP-94 tags
|
||||
// Format: ['imeta', 'url https://...', 'm image/png', 'x abc123', ...]
|
||||
const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`);
|
||||
|
||||
// Create the post content
|
||||
const content = `${companion.name} ${url}`;
|
||||
|
||||
// Publish kind 1 event
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
content: `${companion.name} ${url}`,
|
||||
tags: [['imeta', ...imetaFields]],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Posted!',
|
||||
description: 'Your Blobbi photo has been shared.',
|
||||
});
|
||||
|
||||
// Track daily mission progress for photo action
|
||||
toast({ title: 'Posted!', description: 'Your Blobbi photo has been shared.' });
|
||||
trackDailyMissionProgress('take_photo', 1, user.pubkey);
|
||||
|
||||
// Close the modal after successful post
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to post:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to share:', error);
|
||||
toast({ variant: 'destructive', title: 'Failed to post', description: error instanceof Error ? error.message : 'Please try again.' });
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]);
|
||||
|
||||
const isProcessing = isGenerating || isPosting;
|
||||
if (!open) return null;
|
||||
|
||||
const isProcessing = isDownloading || isSharing;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Camera className="size-5" />
|
||||
Take a Photo
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Capture a polaroid-style photo of {companion.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||
{/* Backdrop — tap to close */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/60 backdrop-blur-sm"
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Polaroid preview - centered */}
|
||||
<div className="flex justify-center py-4">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
{/* Close button — top-right of the container */}
|
||||
<button
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
className="absolute top-3 right-3 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
{/* Polaroid card */}
|
||||
<div className="relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative z-10 flex items-center gap-6 mt-8">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-sky-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #0ea5e9 25%, transparent), color-mix(in srgb, #0ea5e9 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isDownloading ? <Loader2 className="size-6 animate-spin" /> : <Download className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Save</span>
|
||||
</button>
|
||||
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4 mr-2" />
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={isProcessing || !user}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPosting ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4 mr-2" />
|
||||
)}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login hint if not logged in */}
|
||||
{!user && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Log in to post your Blobbi photo
|
||||
</p>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-violet-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #8b5cf6 25%, transparent), color-mix(in srgb, #8b5cf6 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isSharing ? <Loader2 className="size-6 animate-spin" /> : <Share2 className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Post</span>
|
||||
</button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi {
|
||||
size: companion.visualTraits.size,
|
||||
// Metadata
|
||||
seed: companion.seed,
|
||||
tags: companion.allTags,
|
||||
tags: companion.allTags ?? [],
|
||||
// Adult-specific data (for adult form resolution)
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Play, Pause, Music, ListMusic, Podcast, Clock } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
|
||||
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
|
||||
/**
|
||||
* Auto-minimizes the audio player when the user navigates to a different page.
|
||||
|
||||
@@ -3,39 +3,7 @@ import { Award } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
|
||||
/** Parsed NIP-58 badge definition data. */
|
||||
export interface BadgeData {
|
||||
identifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageDimensions?: string;
|
||||
thumbs: Array<{ url: string; dimensions?: string }>;
|
||||
}
|
||||
|
||||
/** Parse a kind 30009 badge definition event into structured data. */
|
||||
export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
|
||||
if (event.kind !== 30009) return null;
|
||||
|
||||
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (!identifier) return null;
|
||||
|
||||
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
|
||||
const description = event.tags.find(([n]) => n === 'description')?.[1];
|
||||
const imageTag = event.tags.find(([n]) => n === 'image');
|
||||
const image = imageTag?.[1];
|
||||
const imageDimensions = imageTag?.[2];
|
||||
|
||||
const thumbs: Array<{ url: string; dimensions?: string }> = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'thumb' && tag[1]) {
|
||||
thumbs.push({ url: tag[1], dimensions: tag[2] });
|
||||
}
|
||||
}
|
||||
|
||||
return { identifier, name, description, image, imageDimensions, thumbs };
|
||||
}
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
|
||||
interface BadgeContentProps {
|
||||
event: NostrEvent;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { parseBadgeDefinition } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { BadgeData } from '@/components/BadgeContent';
|
||||
import type { BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeDisplayItem {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Award } from 'lucide-react';
|
||||
|
||||
import type { BadgeData } from '@/components/BadgeContent';
|
||||
import type { BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
|
||||
import type { CoreThemeColors } from '@/themes';
|
||||
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
|
||||
@@ -12,12 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
export function getColors(tags: string[][]): string[] {
|
||||
return tags
|
||||
.filter(([n]) => n === 'c')
|
||||
.map(([, v]) => v)
|
||||
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
|
||||
}
|
||||
|
||||
|
||||
/** Compute a best-fit grid: cols × rows for n items. */
|
||||
function gridDimensions(n: number): { cols: number; rows: number } {
|
||||
@@ -193,82 +187,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Palette → theme mapping ─────────────────────────────
|
||||
|
||||
function hexContrast(hex1: string, hex2: string): number {
|
||||
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
|
||||
}
|
||||
|
||||
function hexSaturation(hex: string): number {
|
||||
return rgbToHsl(...hexToRgb(hex)).s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
|
||||
* contrast against `bgHsl`. Steps toward white or black depending on which
|
||||
* direction gives better contrast. Returns the adjusted HSL string.
|
||||
*/
|
||||
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
|
||||
const bg = parseHsl(bgHsl);
|
||||
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
|
||||
const { h, s, l } = parseHsl(hsl);
|
||||
|
||||
// Decide direction: go lighter if bg is dark, darker if bg is light
|
||||
const goLighter = bgLum < 0.18;
|
||||
let current = l;
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
current = goLighter
|
||||
? Math.min(100, current + 2)
|
||||
: Math.max(0, current - 2);
|
||||
const rgb = hslToRgb(h, s, current);
|
||||
const lum = getLuminance(...rgb);
|
||||
const lighter = Math.max(bgLum, lum);
|
||||
const darker = Math.min(bgLum, lum);
|
||||
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
|
||||
}
|
||||
|
||||
return formatHsl(h, s, current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map palette hex colors to CoreThemeColors with guaranteed readability:
|
||||
* 1. background = darkest color
|
||||
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
|
||||
* 3. primary = most saturated remaining color; if contrast < 3:1 against
|
||||
* background, adjust its lightness until it passes
|
||||
*/
|
||||
export function paletteToTheme(colors: string[]): CoreThemeColors {
|
||||
if (colors.length === 0) {
|
||||
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
|
||||
}
|
||||
|
||||
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
|
||||
const bgHex = sorted[0];
|
||||
const bgHsl = hexToHslString(bgHex);
|
||||
|
||||
// Text: lightest palette color; override with white/black if contrast is too low
|
||||
const textHex = sorted[sorted.length - 1];
|
||||
let textHsl = hexToHslString(textHex);
|
||||
if (hexContrast(textHex, bgHex) < 4.5) {
|
||||
// Pick white or black — whichever contrasts better
|
||||
const whiteContrast = hexContrast('#ffffff', bgHex);
|
||||
const blackContrast = hexContrast('#000000', bgHex);
|
||||
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
|
||||
}
|
||||
|
||||
// Primary: most saturated of remaining colors; nudge lightness if needed
|
||||
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
|
||||
const pool = rest.length > 0 ? rest : [textHex];
|
||||
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
|
||||
let primaryHsl = hexToHslString(primaryHex);
|
||||
if (hexContrast(primaryHex, bgHex) < 3) {
|
||||
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
|
||||
}
|
||||
|
||||
return { background: bgHsl, text: textHsl, primary: primaryHsl };
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────
|
||||
|
||||
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
@@ -26,7 +26,7 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
|
||||
// ── data hook ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useEventComments(event: NostrEvent | undefined) {
|
||||
function useEventComments(event: NostrEvent | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const aTag = event
|
||||
|
||||
@@ -20,6 +20,7 @@ import { GifPicker } from '@/components/GifPicker';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
@@ -1085,6 +1086,7 @@ export function ComposeBox({
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPointerDown={expand}
|
||||
onFocus={expand}
|
||||
onPaste={handlePaste}
|
||||
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
|
||||
@@ -1487,11 +1489,11 @@ export function ComposeBox({
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import {
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe,
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
|
||||
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -24,7 +25,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useMuteList, type MuteListItem } from '@/hooks/useMuteList';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { FeedEditModal } from '@/components/FeedEditModal';
|
||||
import { buildKindOptions } from '@/components/SavedFeedFiltersEditor';
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
@@ -556,6 +557,183 @@ function FeedTabsSection() {
|
||||
|
||||
{/* Saved Feeds */}
|
||||
<SavedFeedsSection />
|
||||
|
||||
{/* Interests (hashtag & geotag tabs) */}
|
||||
<InterestsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Interests Section ───────────────────────────────────────────────────────
|
||||
|
||||
function InterestsSection() {
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t');
|
||||
const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g');
|
||||
const [newHashtag, setNewHashtag] = useState('');
|
||||
const [newGeotag, setNewGeotag] = useState('');
|
||||
|
||||
const isLoading = isLoadingHashtags || isLoadingGeotags;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleRemoveHashtag = async (tag: string) => {
|
||||
await removeHashtag.mutateAsync(tag);
|
||||
toast({ title: `#${tag} removed from feed tabs` });
|
||||
};
|
||||
|
||||
const handleRemoveGeotag = async (tag: string) => {
|
||||
await removeGeotag.mutateAsync(tag);
|
||||
toast({ title: `${tag} removed from feed tabs` });
|
||||
};
|
||||
|
||||
const handleAddHashtag = async () => {
|
||||
const tag = newHashtag.trim().toLowerCase().replace(/^#/, '');
|
||||
if (!tag) return;
|
||||
if (hashtags.includes(tag)) {
|
||||
toast({ title: `#${tag} is already followed`, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
await addHashtag.mutateAsync(tag);
|
||||
setNewHashtag('');
|
||||
toast({ title: `#${tag} added to feed tabs` });
|
||||
};
|
||||
|
||||
const handleAddGeotag = async () => {
|
||||
const tag = newGeotag.trim().toLowerCase();
|
||||
if (!tag) return;
|
||||
if (geotags.includes(tag)) {
|
||||
toast({ title: `${tag} is already followed`, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
await addGeotag.mutateAsync(tag);
|
||||
setNewGeotag('');
|
||||
toast({ title: `${tag} added to feed tabs` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-4 space-y-4 border-t border-border">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Interest Tabs</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Hashtags and locations you follow appear as tabs on the home feed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hashtags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Hashtags</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="ditto"
|
||||
value={newHashtag}
|
||||
onChange={(e) => setNewHashtag(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
|
||||
className="h-9"
|
||||
disabled={addHashtag.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddHashtag}
|
||||
disabled={addHashtag.isPending || !newHashtag.trim()}
|
||||
size="sm"
|
||||
className="h-9"
|
||||
>
|
||||
{addHashtag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : hashtags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No followed hashtags yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{hashtags.map((tag) => (
|
||||
<div
|
||||
key={`hashtag:${tag}`}
|
||||
className="rounded-lg border border-border/50 bg-secondary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-2 px-2.5">
|
||||
<Hash className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveHashtag(tag)}
|
||||
disabled={removeHashtag.isPending}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
|
||||
aria-label={`Remove #${tag}`}
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Geotags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Locations</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="geohash (e.g. u4pru)"
|
||||
value={newGeotag}
|
||||
onChange={(e) => setNewGeotag(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }}
|
||||
className="h-9"
|
||||
disabled={addGeotag.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddGeotag}
|
||||
disabled={addGeotag.isPending || !newGeotag.trim()}
|
||||
size="sm"
|
||||
className="h-9"
|
||||
>
|
||||
{addGeotag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : geotags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No followed locations yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{geotags.map((tag) => (
|
||||
<div
|
||||
key={`geotag:${tag}`}
|
||||
className="rounded-lg border border-border/50 bg-secondary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-2 px-2.5">
|
||||
<MapPin className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveGeotag(tag)}
|
||||
disabled={removeGeotag.isPending}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
|
||||
import { isCustomEmoji, getCustomEmojiUrl, buildEmojiMap, type ResolvedEmoji } from '@/lib/customEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Threshold at or below which we apply nearest-neighbor scaling. */
|
||||
const PIXEL_ART_MAX = 16;
|
||||
|
||||
interface CustomEmojiImgProps {
|
||||
/** The shortcode name (without colons). */
|
||||
name: string;
|
||||
@@ -14,16 +17,30 @@ interface CustomEmojiImgProps {
|
||||
|
||||
/**
|
||||
* Renders a single custom emoji as an inline image.
|
||||
*
|
||||
* If the image's natural dimensions are 16x16 or smaller, nearest-neighbor
|
||||
* (`image-rendering: pixelated`) scaling is applied to preserve crisp pixels.
|
||||
*/
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
|
||||
const [pixelated, setPixelated] = useState(false);
|
||||
|
||||
const handleLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalWidth <= PIXEL_ART_MAX && img.naturalHeight <= PIXEL_ART_MAX) {
|
||||
setPixelated(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={`:${name}:`}
|
||||
title={`:${name}:`}
|
||||
className={className}
|
||||
style={pixelated ? { imageRendering: 'pixelated' } : undefined}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
|
||||
@@ -25,7 +26,7 @@ export interface EmojiPackData {
|
||||
}
|
||||
|
||||
/** Parse a kind 30030 emoji pack event into structured data. */
|
||||
export function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
|
||||
function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
|
||||
if (event.kind !== 30030) return null;
|
||||
|
||||
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
@@ -172,12 +173,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
className="group relative"
|
||||
title={`:${emoji.shortcode}:`}
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={`:${emoji.shortcode}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain rounded transition-transform group-hover:scale-125"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { SortableList, SortableItem } from '@/components/SortableList';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
@@ -331,14 +332,15 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
|
||||
// For edit mode, fetch fresh event to preserve any tags we don't manage
|
||||
let preservedTags: string[][] = [];
|
||||
let prev: NostrEvent | null = null;
|
||||
if (isEditMode) {
|
||||
const fresh = await fetchFreshEvent(nostr, {
|
||||
prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [30030],
|
||||
authors: [user.pubkey],
|
||||
'#d': [resolvedId],
|
||||
});
|
||||
if (fresh) {
|
||||
preservedTags = fresh.tags.filter(
|
||||
if (prev) {
|
||||
preservedTags = prev.tags.filter(
|
||||
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
|
||||
);
|
||||
}
|
||||
@@ -356,7 +358,8 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
kind: 30030,
|
||||
content: '',
|
||||
tags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
// Clean up blob URLs
|
||||
for (const e of emojis) {
|
||||
@@ -506,11 +509,10 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
>
|
||||
<div className="flex items-center gap-2 pr-2 py-1.5">
|
||||
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
|
||||
@@ -375,11 +376,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{emoji.customUrl ? (
|
||||
<img
|
||||
src={emoji.customUrl}
|
||||
alt={`:${emoji.name}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.name}
|
||||
url={emoji.customUrl}
|
||||
className="size-5 object-contain shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
|
||||
+31
-38
@@ -14,7 +14,7 @@ import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useInfiniteHotFeed } from '@/hooks/useTrending';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
@@ -22,13 +22,15 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { useCuratedDittoFeed } from '@/hooks/useCuratedDittoFeed';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { diversifyFeedPages } from '@/lib/feedDiversity';
|
||||
import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { SavedFeed } from '@/contexts/AppContext';
|
||||
@@ -36,23 +38,6 @@ import type { SavedFeed } from '@/contexts/AppContext';
|
||||
type CoreFeedTab = 'follows' | 'global' | 'communities' | 'ditto';
|
||||
type FeedTab = CoreFeedTab | string; // string = saved feed id
|
||||
|
||||
/** Curated kinds for the logged-out homepage: unique Ditto content types. */
|
||||
const LANDING_KINDS = [
|
||||
36767, // Themes
|
||||
37381, // Magic Decks
|
||||
3367, // Color Moments
|
||||
37516, // Treasures
|
||||
7516, // Treasures (Found Logs)
|
||||
30030, // Emoji Packs
|
||||
30009, // Badge Definitions
|
||||
10008, // Profile Badges
|
||||
30008, // Profile Badges (legacy)
|
||||
31124, // Blobbi
|
||||
];
|
||||
|
||||
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
|
||||
const LANDING_WEBXDC_FILTER = { kinds: [1063], '#m': ['application/x-webxdc'] };
|
||||
|
||||
interface FeedProps {
|
||||
/** Override the kinds list instead of using feed settings. */
|
||||
kinds?: number[];
|
||||
@@ -74,6 +59,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const { savedFeeds } = useSavedFeeds();
|
||||
const { hashtags } = useInterests();
|
||||
const { hashtags: geotags } = useInterests('g');
|
||||
const { data: curatorFollowList, isError: isCuratorError } = useCuratorFollowList();
|
||||
|
||||
// Tab settings from localStorage
|
||||
const showGlobalFeed = (() => {
|
||||
@@ -150,21 +136,17 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
|
||||
);
|
||||
|
||||
// "Hot" sorted feed query (used when logged out on the home page, or on the Ditto tab)
|
||||
// Shows curated "otherstuff" kinds instead of kind 1. Webxdc needs a
|
||||
// separate filter with a MIME-type tag constraint.
|
||||
const topQuery = useInfiniteHotFeed(
|
||||
LANDING_KINDS,
|
||||
// Curated Ditto feed: latest content from the curator's follow list.
|
||||
const topQuery = useCuratedDittoFeed(
|
||||
curatorFollowList,
|
||||
useTopFeedForLoggedOut || !!useDittoTab,
|
||||
undefined,
|
||||
[LANDING_WEBXDC_FILTER],
|
||||
);
|
||||
|
||||
// Unify the two query shapes behind a single interface
|
||||
const useDittoQuery = useTopFeedForLoggedOut || useDittoTab;
|
||||
const activeQuery = useDittoQuery ? topQuery : feedQuery;
|
||||
const queryKey = useMemo(
|
||||
() => useDittoQuery ? ['infinite-hot-feed', LANDING_KINDS.join(',')] : ['feed', activeTab],
|
||||
() => useDittoQuery ? ['ditto-curated-feed'] : ['feed', activeTab],
|
||||
[useDittoQuery, activeTab],
|
||||
);
|
||||
|
||||
@@ -204,16 +186,25 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (useDittoQuery) {
|
||||
return (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
|
||||
.flat()
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
|
||||
// Deduplicate and filter each page independently, then diversify
|
||||
// page-by-page so earlier pages never change when new pages arrive.
|
||||
const dedupedPages = (rawData.pages as unknown as import('@nostrify/nostrify').NostrEvent[][])
|
||||
.map((page) =>
|
||||
page
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at })),
|
||||
);
|
||||
|
||||
// Reorder for content-type diversity: cap any single type at 20%
|
||||
// per page and enforce a minimum gap of 4 positions between same-type
|
||||
// items, with gap state carrying across page boundaries.
|
||||
return diversifyFeedPages(dedupedPages);
|
||||
}
|
||||
|
||||
return (rawData.pages as unknown as { items: FeedItem[] }[])
|
||||
@@ -228,7 +219,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
});
|
||||
}, [rawData?.pages, muteItems, useDittoQuery]);
|
||||
|
||||
const showSkeleton = isPending || (isLoading && !rawData);
|
||||
// Show skeletons while loading, but not if the curator list query errored
|
||||
// (that would leave logged-out users staring at infinite skeletons).
|
||||
const showSkeleton = (isPending || (isLoading && !rawData)) && !(useDittoQuery && isCuratorError);
|
||||
|
||||
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
|
||||
// Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed.
|
||||
|
||||
@@ -18,14 +18,13 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
|
||||
import {
|
||||
buildKindOptions,
|
||||
MultiKindPicker,
|
||||
AuthorChip,
|
||||
AuthorFilterDropdown,
|
||||
ScopeToggle,
|
||||
ListPackPicker,
|
||||
parseSelectedKinds,
|
||||
} from '@/components/SavedFeedFiltersEditor';
|
||||
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
|
||||
@@ -21,26 +21,17 @@ import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parsePackEvent } from '@/lib/packUtils';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
|
||||
/** Parse a follow pack / starter pack event into structured data. */
|
||||
function parsePackEvent(event: NostrEvent) {
|
||||
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||
const title = getTag('title') || getTag('name') || 'Untitled Pack';
|
||||
const description = getTag('description') || getTag('summary') || '';
|
||||
const image = getTag('image') || getTag('thumb') || getTag('banner');
|
||||
const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk);
|
||||
|
||||
return { title, description, image, pubkeys };
|
||||
}
|
||||
|
||||
type Tab = 'feed' | 'members';
|
||||
|
||||
// ─── Feed Tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
const { posts, isLoading } = useStreamPosts('', {
|
||||
@@ -101,7 +92,7 @@ function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
|
||||
// ─── Members Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PackMembersTab({
|
||||
export function PackMembersTab({
|
||||
pubkeys,
|
||||
membersMap,
|
||||
membersLoading,
|
||||
@@ -186,34 +177,32 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
setIsFollowingAll(true);
|
||||
try {
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
|
||||
const followEvents = await nostr.query(
|
||||
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
|
||||
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
|
||||
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
|
||||
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
|
||||
|
||||
const latestEvent = followEvents.length > 0
|
||||
? followEvents.reduce((latest, current) => current.created_at > latest.created_at ? current : latest)
|
||||
: null;
|
||||
|
||||
const existingFollows = latestEvent
|
||||
? latestEvent.tags.filter(([name]) => name === 'p').map(([, pk]) => pk)
|
||||
: [];
|
||||
|
||||
const allFollows = [...new Set([...existingFollows, ...pubkeys])];
|
||||
const added = pubkeys.filter((pk) => !existingFollows.includes(pk));
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = pubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ['p', pk]);
|
||||
const added = newPTags.length;
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: latestEvent?.content ?? '',
|
||||
tags: allFollows.map((pk) => ['p', pk]),
|
||||
content: prev?.content ?? '',
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Following all!',
|
||||
description: added.length > 0
|
||||
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
|
||||
description: added > 0
|
||||
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
|
||||
: 'You were already following everyone in this pack.',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -357,7 +346,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
|
||||
/** Individual member card in the follow pack. */
|
||||
function MemberCard({
|
||||
export function MemberCard({
|
||||
pubkey,
|
||||
metadata,
|
||||
isFollowed,
|
||||
@@ -437,7 +426,7 @@ function MemberCard({
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCardSkeleton() {
|
||||
export function MemberCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import QRCode from 'qrcode';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
|
||||
|
||||
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
|
||||
const MIN_QR_CONTRAST = 3;
|
||||
|
||||
/** Saturation threshold (%) above which a color is considered "colorful". */
|
||||
const COLORFUL_SAT_MIN = 15;
|
||||
/** Lightness range within which a color appears visually colorful. */
|
||||
const COLORFUL_L_MIN = 20;
|
||||
const COLORFUL_L_MAX = 80;
|
||||
|
||||
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
|
||||
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
|
||||
if (!raw) return null;
|
||||
const { h, s, l } = parseHsl(raw);
|
||||
if ([h, s, l].some(isNaN)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function darkenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l > 0 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.max(0, l - 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function lightenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l < 100 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.min(100, l + 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best module color from primary and foreground.
|
||||
*
|
||||
* Strongly prefers primary since it carries the theme's brand identity.
|
||||
* Only picks foreground if it is colorful (saturation > threshold) AND
|
||||
* has significantly better contrast (> 1.5x) against the QR background.
|
||||
*/
|
||||
function pickModuleColor(
|
||||
primary: { h: number; s: number; l: number },
|
||||
foreground: { h: number; s: number; l: number } | null,
|
||||
bgRgb: [number, number, number],
|
||||
): { h: number; s: number; l: number } {
|
||||
const fgIsColorful = foreground
|
||||
&& foreground.s >= COLORFUL_SAT_MIN
|
||||
&& foreground.l >= COLORFUL_L_MIN
|
||||
&& foreground.l <= COLORFUL_L_MAX;
|
||||
|
||||
if (!fgIsColorful) return primary;
|
||||
|
||||
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
|
||||
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
|
||||
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
|
||||
const fgContrast = getContrastRatio(fgRgb, bgRgb);
|
||||
|
||||
// Foreground must be significantly better to override primary
|
||||
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive QR module and background hex colors from the active theme.
|
||||
*
|
||||
* Light themes: white background, best themed color as modules (darkened if needed).
|
||||
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
|
||||
*
|
||||
* "Best themed color" is --primary by default. If --foreground is colorful
|
||||
* (saturation > 15%) and offers better contrast, it wins instead.
|
||||
*/
|
||||
function getThemedQRColors(): { dark: string; light: string } {
|
||||
const primary = readCssHsl('--primary');
|
||||
const foreground = readCssHsl('--foreground');
|
||||
const background = readCssHsl('--background');
|
||||
|
||||
if (!primary) return { dark: '#000000', light: '#ffffff' };
|
||||
|
||||
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
|
||||
|
||||
if (!isDark) {
|
||||
const white: [number, number, number] = [255, 255, 255];
|
||||
const module = pickModuleColor(primary, foreground, white);
|
||||
return { dark: darkenToContrast(module, white), light: '#ffffff' };
|
||||
}
|
||||
|
||||
if (!background) return { dark: '#ffffff', light: '#000000' };
|
||||
const bgRgb = hslToRgb(background.h, background.s, background.l);
|
||||
const module = pickModuleColor(primary, foreground, bgRgb);
|
||||
return {
|
||||
dark: lightenToContrast(module, bgRgb),
|
||||
light: rgbToHex(...bgRgb),
|
||||
};
|
||||
}
|
||||
|
||||
interface FollowQRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey ?? '');
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = user ? metadata?.name || genUserName(user.pubkey) : '';
|
||||
|
||||
const npub = user ? nip19.npubEncode(user.pubkey) : '';
|
||||
const followUrl = npub ? `${window.location.origin}/follow/${npub}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!followUrl || !open) return;
|
||||
|
||||
const { dark, light } = getThemedQRColors();
|
||||
|
||||
QRCode.toDataURL(followUrl, {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
.then(setQrDataUrl)
|
||||
.catch(console.error);
|
||||
}, [followUrl, open]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(followUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-6 flex flex-col items-center gap-5 rounded-2xl">
|
||||
<DialogTitle className="sr-only">Share follow link</DialogTitle>
|
||||
|
||||
{/* Avatar + name */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-16 ring-2 ring-secondary">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="text-xl font-semibold">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Scan to follow <span className="text-foreground font-medium">{displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Follow QR code"
|
||||
className="w-full rounded-xl border border-border"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square rounded-xl border border-border bg-muted animate-pulse" />
|
||||
)}
|
||||
|
||||
{/* Copy link */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary flex-shrink-0" />
|
||||
: <Copy className="size-3.5 flex-shrink-0" />}
|
||||
<span className="truncate max-w-64">{followUrl}</span>
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseBadgeDefinition } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAwardBadge } from '@/hooks/useAwardBadge';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user