Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff758b078c | |||
| fe5221e973 | |||
| c48079406d | |||
| 76623cd510 | |||
| f1b0868e30 | |||
| ffdf6f0f36 | |||
| c965ff27c4 | |||
| f5f7c90ce4 | |||
| 4e5dbed3d2 | |||
| 508a16234f | |||
| 4ecb3209bd | |||
| 286572777b | |||
| 7fd4b7ab69 | |||
| c9525a0233 | |||
| 0b9cd5e1cb | |||
| a2600d1caa | |||
| 0722d900a2 | |||
| 918814371c |
@@ -43,7 +43,6 @@ These APIs are **completely unavailable** (return `undefined`, `null`, or throw)
|
||||
| **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
|
||||
@@ -66,7 +65,6 @@ These APIs **still work** under Lockdown Mode and can be relied on.
|
||||
|
||||
- **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
|
||||
|
||||
@@ -95,7 +93,7 @@ Several blocked web APIs have Capacitor plugin equivalents that bypass WKWebView
|
||||
|
||||
### 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.
|
||||
The report used a scoring heuristic (7/11 key APIs blocked = 68%) 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
|
||||
|
||||
@@ -106,7 +104,7 @@ For exact error messages, navigator properties, weight scores, and per-API diagn
|
||||
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.
|
||||
2. **Never depend on IndexedDB** as the sole storage mechanism. 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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
============================================================
|
||||
LOCKDOWN MODE DETECTOR REPORT
|
||||
2026-04-06T23:40:58.170Z
|
||||
2026-04-06T20:53:36.098Z
|
||||
============================================================
|
||||
|
||||
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)
|
||||
7 of 11 key APIs are blocked, consistent with iOS/macOS Lockdown Mode.
|
||||
Score: 68% (7/11 key APIs blocked)
|
||||
|
||||
============================================================
|
||||
API TEST RESULTS (detailed)
|
||||
@@ -136,10 +136,10 @@ API TEST RESULTS (detailed)
|
||||
------------------------------------------------------------
|
||||
[AVAILABLE] JIT Compilation (weight: 2)
|
||||
JavaScript JIT optimization heuristic
|
||||
Result: 99.0ms for 1M iterations (JIT likely)
|
||||
Result: 110.0ms for 1M iterations (JIT likely)
|
||||
Diagnostics:
|
||||
running 1,000,000 iterations of Math.sqrt*Math.sin...
|
||||
elapsed: 99.00ms
|
||||
elapsed: 110.00ms
|
||||
sum (to prevent dead-code elimination): -681.7597
|
||||
threshold: <150ms suggests JIT active
|
||||
verdict: likely JIT
|
||||
@@ -187,15 +187,6 @@ API TEST RESULTS (detailed)
|
||||
'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
|
||||
============================================================
|
||||
|
||||
+2
-32
@@ -145,9 +145,8 @@ build-apk:
|
||||
- npx vite build -l error
|
||||
- cp dist/index.html dist/404.html
|
||||
|
||||
# Sync web assets to Capacitor Android project and register local plugins
|
||||
# Sync web assets to Capacitor Android project
|
||||
- npx cap sync android
|
||||
- node scripts/patch-cap-config.mjs
|
||||
|
||||
# Build signed release APK
|
||||
- cd android && chmod +x gradlew && ./gradlew assembleRelease bundleRelease && cd ..
|
||||
@@ -219,7 +218,7 @@ publish-zapstore:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
SIGN_WITH: $ZAPSTORE_BUNKER_URL
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
|
||||
BLOSSOM_URL: "https://blossom.ditto.pub"
|
||||
script:
|
||||
- go install github.com/zapstore/zsp@latest
|
||||
@@ -235,32 +234,3 @@ publish-zapstore:
|
||||
- sed -i "2i release_source:\ ./${APK_PATH}" zapstore.yaml
|
||||
- sed -i "2i version:\ ${VERSION}" zapstore.yaml
|
||||
- zsp publish --quiet --skip-metadata --skip-preview zapstore.yaml
|
||||
|
||||
publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- build-apk
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- gem install fastlane --no-document
|
||||
|
||||
# Decode base64-encoded service account JSON to a temp file
|
||||
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
|
||||
|
||||
# Upload the AAB to Google Play production track
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Ditto.aab
|
||||
--package_name pub.ditto.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--skip_upload_metadata
|
||||
--skip_upload_changelogs
|
||||
--skip_upload_images
|
||||
--skip_upload_screenshots
|
||||
--skip_upload_apk
|
||||
|
||||
# Clean up
|
||||
- rm -f /tmp/play-service-account.json
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
Thanks for contributing to Ditto! Please read [CONTRIBUTING.md](CONTRIBUTING.md) in full before submitting -- it covers everything you need to get your MR accepted.
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Link the GitLab issue. MRs without a linked issue will not be reviewed. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## What Changed
|
||||
|
||||
<!-- 1-3 sentences: what you changed and why. -->
|
||||
|
||||
## Live Preview
|
||||
|
||||
<!-- REQUIRED for UI changes. Deploy your branch and paste the URL. -->
|
||||
<!-- Example: npx surge dist your-branch.surge.sh -->
|
||||
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- REQUIRED for UI changes. Show before and after. -->
|
||||
<!-- Write "N/A -- no UI changes" only if this MR has zero visual impact. -->
|
||||
|
||||
**Before:**
|
||||
|
||||
|
||||
**After:**
|
||||
|
||||
## Philosophy Alignment
|
||||
|
||||
<!-- Answer this question for your change: -->
|
||||
<!-- "Does this make Ditto more magnetic, more threatening to the status quo, -->
|
||||
<!-- and more peaceful to inhabit?" -->
|
||||
<!-- See: https://about.ditto.pub/philosophy -->
|
||||
<!-- For bug fixes: "Bug fix -- restores intended behavior" is acceptable. -->
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- Steps a reviewer can follow to verify this works. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
<!-- Complete ALL items. MRs with unchecked boxes will not be reviewed. -->
|
||||
<!-- Check a box: replace [ ] with [x] -->
|
||||
|
||||
### Process
|
||||
|
||||
- [ ] I read `AGENTS.md` before starting
|
||||
- [ ] I read the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- [ ] I used plan/research mode before writing code
|
||||
- [ ] I used Claude Opus 4.6 (or equivalent frontier model)
|
||||
|
||||
### Self-review
|
||||
|
||||
Copy-paste this into your AI tool and fix any findings before submitting:
|
||||
|
||||
> Review this diff against the self-review checklist in CONTRIBUTING.md step 8. Read that file first, then check every item. For each finding, state the file, line, and issue.
|
||||
|
||||
- [ ] I ran the self-review prompt above and addressed all findings
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] I ran `npm run test` locally and it passes
|
||||
- [ ] I tested the change manually in the browser
|
||||
@@ -409,74 +409,6 @@ Without filtering approvals by the moderator list, anyone could publish kind 455
|
||||
|
||||
Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.).
|
||||
|
||||
#### Sanitizing URLs from Event Data
|
||||
|
||||
**CRITICAL**: Any URL extracted from Nostr event tags, content, or metadata fields is **untrusted user input**. Malicious URLs can cause harm in many ways beyond `javascript:` XSS — `data:` URIs for resource exhaustion, `http://` URLs leaking user IPs without TLS, relative paths triggering unintended requests to the app's own origin, and more. Reasoning about which rendering context is "safe enough" to skip sanitization is fragile and error-prone.
|
||||
|
||||
**Rule: sanitize every event-sourced URL unconditionally**, regardless of where it will be used (`href`, `img src`, `style`, etc.). Use `sanitizeUrl()` from `@/lib/sanitizeUrl`:
|
||||
|
||||
```typescript
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// Single URL — returns the normalised href, or undefined if not valid https
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
if (url) {
|
||||
// safe to use in any context
|
||||
}
|
||||
|
||||
// Array of URLs — filter out invalid entries
|
||||
const links = getAllTags(event.tags, 'r')
|
||||
.map(([, v]) => sanitizeUrl(v))
|
||||
.filter((v): v is string => !!v);
|
||||
```
|
||||
|
||||
`sanitizeUrl` accepts `string | undefined | null` and returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
|
||||
|
||||
**Best practice — sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
|
||||
|
||||
**When sanitization is NOT required:**
|
||||
- URLs extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`)
|
||||
- Hardcoded or application-generated URLs (relay configs, internal routes, etc.)
|
||||
- URLs displayed as plain text without being placed into any HTML attribute or CSS value
|
||||
|
||||
#### Preventing CSS Injection from Event Data
|
||||
|
||||
**CRITICAL**: Any value from a Nostr event that is interpolated into a CSS string (inside a `<style>` element or inline `style` attribute) is a CSS injection vector. A malicious value containing `"`, `)`, `}`, or `;` can break out of the CSS context and inject arbitrary rules — for example, overlaying phishing content or hiding UI elements.
|
||||
|
||||
**Common CSS injection surfaces:**
|
||||
- `background-image: url("${url}")` — a URL with `"); body { display:none }` breaks out
|
||||
- `font-family: "${family}"` — a family name with `"; } body { visibility:hidden } .x {` breaks out
|
||||
- `@font-face { src: url("${url}") }` — same risk as background URLs
|
||||
|
||||
**Mitigation strategy — sanitize at the parse layer:**
|
||||
|
||||
1. **URLs in CSS `url()` values**: Pass through `sanitizeUrl()` at parse time. The `URL` constructor normalises the string, percent-encoding characters like `"`, `)`, and `\` that could escape the CSS context. Invalid or non-`https:` URLs are rejected entirely. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
|
||||
2. **Strings in CSS declarations** (e.g. font family names): Use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens, underscores, apostrophes, and periods are permitted. Everything else is stripped.
|
||||
|
||||
```typescript
|
||||
// ❌ UNSAFE — raw event data interpolated into CSS
|
||||
const bgUrl = getTagValue(event.tags, 'bg');
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
|
||||
const family = getTagValue(event.tags, 'f');
|
||||
style.textContent = `html { font-family: "${family}"; }`;
|
||||
|
||||
// ✅ SAFE — URLs validated, strings sanitised
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
const bgUrl = sanitizeUrl(getTagValue(event.tags, 'bg'));
|
||||
if (bgUrl) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); }`;
|
||||
}
|
||||
|
||||
// For non-URL strings, allowlist safe characters only
|
||||
const safeFamily = family.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
|
||||
style.textContent = `html { font-family: "${safeFamily}"; }`;
|
||||
```
|
||||
|
||||
**Rule of thumb**: Never interpolate untrusted strings into CSS without sanitisation. If it's a URL, use `sanitizeUrl()`. If it's any other string, strip characters that can break out of the CSS string context.
|
||||
|
||||
### The `useNostr` Hook
|
||||
|
||||
The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively.
|
||||
@@ -1403,10 +1335,6 @@ Run available tools in this priority order:
|
||||
|
||||
The validation ensures code quality and catches errors before deployment, regardless of the development environment.
|
||||
|
||||
### Contributing Guide
|
||||
|
||||
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
|
||||
|
||||
### Using Git
|
||||
|
||||
If git is available in your environment (through a `shell` tool, or other git-specific tools), you should utilize `git log` to understand project history. Use `git status` and `git diff` to check the status of your changes, and if you make a mistake use `git checkout` to restore files.
|
||||
@@ -1484,7 +1412,7 @@ The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages:
|
||||
2. **deploy** - Builds and deploys to nsite via nsyte (`deploy-nsite` job, default branch only)
|
||||
3. **build** - Builds a signed release APK (`build-apk` job, tags only)
|
||||
4. **release** - Creates a GitLab Release with the APK artifact (tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only) and AAB to Google Play (`publish-google-play` job, tags only)
|
||||
5. **publish** - Publishes the APK to Zapstore (`publish-zapstore` job, tags only)
|
||||
|
||||
### Creating a Release
|
||||
|
||||
@@ -1494,7 +1422,7 @@ Releases are triggered by pushing a version tag. Use the npm script:
|
||||
npm run release
|
||||
```
|
||||
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` stages.
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, and `publish-zapstore` stages.
|
||||
|
||||
### Zapstore Publishing
|
||||
|
||||
@@ -1586,29 +1514,4 @@ The `--use-fallback-relays` and `--use-fallback-servers` flags also include nsyt
|
||||
To rotate the nsite credential:
|
||||
1. Revoke the old bunker connection in your signer app
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
|
||||
### Google Play Publishing
|
||||
|
||||
The project automatically publishes Android AABs (App Bundles) to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). The `publish-google-play` CI job runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD Variables** (Settings > CI/CD > Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
|
||||
|
||||
#### Initial Setup (one-time)
|
||||
|
||||
1. Create or reuse a project in the [Google Cloud Console](https://console.cloud.google.com/projectcreate)
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`
|
||||
5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
|
||||
|
||||
#### Key Points
|
||||
|
||||
- The job uploads the signed AAB (not APK) since Google Play requires App Bundles
|
||||
- Uploads go directly to the **production** track -- Google's review process still applies before the update reaches users
|
||||
- Metadata, screenshots, and changelogs are managed in the Play Console, not via CI (the job uses `--skip_upload_metadata` etc.)
|
||||
- The same signing keystore used for Zapstore is used here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`)
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings
|
||||
@@ -1,67 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
# Contributing to Ditto
|
||||
|
||||
We welcome contributions, but we have high standards. Ditto is a carefully designed product with a specific vision, and every merge request must meet that bar. This guide exists to help you succeed.
|
||||
|
||||
**Required reading before you start:**
|
||||
|
||||
- [Ditto Philosophy](https://about.ditto.pub/philosophy) -- the product vision. Your change must align with it.
|
||||
- [Contributing Guide](https://about.ditto.pub/contributing) -- the upstream contribution process.
|
||||
- `AGENTS.md` in this repo -- the codebase conventions. Your AI tool should load this file.
|
||||
|
||||
## Understanding Ditto
|
||||
|
||||
Ditto is a carnival, not a platform. Before contributing, you need to understand what that means.
|
||||
|
||||
### The product decision filter
|
||||
|
||||
Every change to Ditto should pass this test:
|
||||
|
||||
> *Does this make Ditto more magnetic, more threatening to the status quo, and more peaceful to inhabit?*
|
||||
|
||||
- **Magnetic** -- Ditto attracts through experience, not ideology. People don't need to understand Nostr to love it. They need to feel something they haven't felt online since the early web. Features should be odd, intriguing, and captivating -- not generic social media clones.
|
||||
- **Threatening to the status quo** -- Ditto threatens mainstream platforms when someone opens it and thinks: *"Why can't my platform do this?"* Theming, games, treasure hunts, interoperable micro-apps -- these are things walled gardens can't replicate.
|
||||
- **Peaceful to inhabit** -- Ditto displaces argument with creation, conformity with expression, and consumption with participation. No ads, no engagement-optimized algorithms, no outrage incentives.
|
||||
|
||||
If a change does all three, it belongs. If it only does one, think harder. If it does none, it doesn't belong here.
|
||||
|
||||
### What Ditto is NOT
|
||||
|
||||
- A Twitter/X clone with decentralization bolted on
|
||||
- A place to replicate features that mainstream platforms already do well
|
||||
- A showcase for generic UI components or boilerplate social features
|
||||
|
||||
### What Ditto IS
|
||||
|
||||
- A convergence point for interoperable Nostr experiences (games, treasure hunts, magic decks, themes, color moments, live streams, and things nobody has imagined yet)
|
||||
- A place where profiles feel like worlds, not business cards
|
||||
- The most fun you've had on the internet in years
|
||||
|
||||
Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete vision.
|
||||
|
||||
## What we accept
|
||||
|
||||
### Bug fixes
|
||||
|
||||
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
|
||||
|
||||
### New features and significant changes
|
||||
|
||||
Every feature MR must link to an existing open issue and clearly align with the [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
|
||||
If you have an idea for a feature that doesn't have an issue yet:
|
||||
|
||||
1. Build it as a standalone Nostr app first (see [Contributing Guide](https://about.ditto.pub/contributing)).
|
||||
2. Prove it works and get user feedback.
|
||||
3. Open an issue to discuss integration.
|
||||
|
||||
**Feature MRs that don't link to an issue or don't align with the Ditto Philosophy will be closed.** Our open issues are our internal roadmap -- some require deep product context. If your implementation doesn't match the product vision, it will be closed regardless of code quality.
|
||||
|
||||
## Required tools
|
||||
|
||||
- **Claude Opus 4.6** (or the latest frontier model) -- not Sonnet, not GPT-4o, not local models. Quality depends on model quality.
|
||||
- **An AI coding agent with plan/research mode** -- [OpenCode](https://opencode.ai), [Shakespeare](https://shakespeare.diy), Cursor, or similar.
|
||||
- **Node.js 22+** and npm 10.9.4+.
|
||||
|
||||
## The contribution workflow
|
||||
|
||||
Follow these steps in order. Skipping steps is the most common reason MRs are rejected.
|
||||
|
||||
### 1. Ask: does anyone need this?
|
||||
|
||||
Before writing a single line of code, answer this honestly. For bug fixes this is straightforward -- someone hit the bug. For features, it requires more thought. Is there evidence of real user demand? Is the underlying technology mature enough? A beautifully written feature for a nonexistent user base is the wrong thing to build. If you can't point to a concrete user need, reconsider.
|
||||
|
||||
### 2. Understand the issue
|
||||
|
||||
Read the issue thoroughly. If anything is unclear, ask in the issue comments before writing code. Understand not just *what* to change, but *why* -- what problem does this solve for users?
|
||||
|
||||
### 3. Read the codebase conventions
|
||||
|
||||
Read `AGENTS.md` in the repo root. This is the single source of truth for how code should be written in this project. Your AI tool should load this file automatically. If it doesn't, paste it in or configure your tool to read it.
|
||||
|
||||
### 4. Read the philosophy
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy). Ditto is a carnival, not a platform. Your change should feel like it belongs in Ditto -- not like it was transplanted from a generic social media template. Apply the product decision filter above.
|
||||
|
||||
### 5. Plan before you code
|
||||
|
||||
Start your AI tool in **plan mode** (or research/think mode). Spend the first few prompts:
|
||||
|
||||
- Exploring the existing codebase to understand how similar features are implemented
|
||||
- Reading the files you'll need to modify
|
||||
- Proposing an approach
|
||||
|
||||
Do not write code until you have a plan. The most expensive mistake is implementing the wrong approach.
|
||||
|
||||
### 6. Implement
|
||||
|
||||
Switch to code mode and implement your plan. Use Opus 4.6 or equivalent.
|
||||
|
||||
### 7. Run the test suite
|
||||
|
||||
```sh
|
||||
npm run test
|
||||
```
|
||||
|
||||
This runs type-checking, linting, unit tests, and a production build. All must pass. Do not submit an MR with a failing test suite.
|
||||
|
||||
### 8. Self-review
|
||||
|
||||
Run this prompt against your diff (copy the full `git diff` output and paste it to your AI tool along with this prompt):
|
||||
|
||||
```
|
||||
Review this diff as if you are a senior maintainer of this codebase who has to
|
||||
maintain it long-term. For each finding, state the file, line, and issue.
|
||||
|
||||
- [ ] Does the diff contain changes that weren't requested? Flag anything out of scope.
|
||||
- [ ] Is there dead code, commented-out blocks, or debug artifacts left in?
|
||||
- [ ] Are there placeholder comments like "// In a real app..." or "// TODO: implement"?
|
||||
- [ ] For every value displayed to a user, can you trace it from source to render without a gap?
|
||||
- [ ] Are error, loading, and empty states all handled -- and in the right order?
|
||||
- [ ] Does a mutation reflect in the UI without requiring a manual refresh?
|
||||
- [ ] Is there a new read/write path that assumes fresh data but could get a stale cache?
|
||||
- [ ] For replaceable/addressable Nostr events: is fetchFreshEvent used before mutation?
|
||||
- [ ] Does anything new block the critical render path or fire N+1 network requests?
|
||||
- [ ] Are Nostr queries efficient (combined kinds, relay-level filtering vs client-side)?
|
||||
- [ ] Are user inputs used in queries or rendered as content without sanitization?
|
||||
- [ ] Were existing patterns/conventions in AGENTS.md ignored in favor of something novel?
|
||||
- [ ] Are secrets, keys, or env-specific values hardcoded?
|
||||
- [ ] Does the code use the `any` type anywhere?
|
||||
- [ ] Is the code Capacitor-compatible (no `<a download>`, no `window.open()`)?
|
||||
- [ ] Are new Nostr event kinds documented in NIP.md with links to relevant specs?
|
||||
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
|
||||
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
|
||||
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
Then answer: "If you were the people who have to maintain this codebase and deal
|
||||
with all long-term issues, what would be your biggest concerns about this
|
||||
implementation?"
|
||||
```
|
||||
|
||||
Address every finding before submitting.
|
||||
|
||||
### 9. Deploy a live preview
|
||||
|
||||
Deploy your branch so reviewers can test it without pulling your code:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npx surge dist your-branch-name.surge.sh
|
||||
```
|
||||
|
||||
Or use Netlify, Vercel, or any static hosting. Include the live preview URL in your MR description.
|
||||
|
||||
### 10. Take screenshots
|
||||
|
||||
Capture before and after screenshots of any UI changes. Include them directly in the MR description. If your change has no visual component, state that explicitly.
|
||||
|
||||
### 11. Submit
|
||||
|
||||
Fill out every field in the MR template. Incomplete MRs will not be reviewed.
|
||||
|
||||
## What gets your MR closed without review
|
||||
|
||||
- No linked issue
|
||||
- Feature MRs with no clear alignment with the [Ditto Philosophy](https://about.ditto.pub/philosophy)
|
||||
- Features that fail the product decision filter (not magnetic, not threatening to the status quo, not peaceful)
|
||||
- Incomplete MR template (missing checklist, screenshots, or preview URL)
|
||||
- Changes that go beyond what was asked for (scope creep)
|
||||
- Placeholder code, dead code, or debug artifacts
|
||||
- Evidence of low-quality AI generation ("In a real application..." comments, hallucinated APIs, generic template code)
|
||||
- Failing test suite
|
||||
- No evidence of planning (code-first, think-later approach produces recognizable patterns)
|
||||
- Undocumented Nostr event kinds (new kinds must be in NIP.md)
|
||||
- Large binary assets committed to git (images >100KB, fonts, videos)
|
||||
- Security issues (dangerouslySetInnerHTML, eval, innerHTML, unsanitized user input)
|
||||
|
||||
## MR review process
|
||||
|
||||
1. The CI pipeline validates your MR description automatically. If it fails, read the error message and fix your MR description.
|
||||
2. Maintainers will review your MR when all CI checks pass and the template is complete.
|
||||
3. If changes are requested, address them promptly. Stale MRs will be closed.
|
||||
|
||||
We appreciate your interest in contributing. These standards exist because reviewing a low-quality MR takes 3x longer than doing the work ourselves. Help us help you by following the process.
|
||||
@@ -138,17 +138,6 @@ src/
|
||||
public/ Static assets, icons, manifest
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions but have high standards. Please read the full [Contributing Guide](CONTRIBUTING.md) before submitting a merge request. The short version:
|
||||
|
||||
- **Bug fixes**: One bug, one MR. Keep it small and focused.
|
||||
- **New features**: Must link to an existing issue and align with the [Ditto Philosophy](https://about.ditto.pub/philosophy).
|
||||
- **Required**: Live preview URL, before/after screenshots, completed self-review checklist.
|
||||
- **Required tools**: Claude Opus 4.6 (or latest frontier model), an AI coding agent with plan mode.
|
||||
|
||||
Read the [Ditto Philosophy](https://about.ditto.pub/philosophy) to understand what Ditto is and isn't.
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.5"
|
||||
versionName "2.6.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -11,11 +11,9 @@ 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(':capgo-capacitor-autofill-save-password')
|
||||
implementation project(':capacitor-secure-storage-plugin')
|
||||
implementation project(':capacitor-status-bar')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.app.ForegroundServiceStartNotAllowedException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
@@ -17,10 +14,6 @@ import org.json.JSONArray;
|
||||
/**
|
||||
* Capacitor plugin that allows the JS layer to configure the native
|
||||
* notification polling service with the user's pubkey and relay URLs.
|
||||
*
|
||||
* Supports two notification styles:
|
||||
* - "push" (default): no foreground service, relies on push notifications
|
||||
* - "persistent": starts NotificationRelayService as a foreground service
|
||||
*/
|
||||
@CapacitorPlugin(name = "DittoNotification")
|
||||
public class DittoNotificationPlugin extends Plugin {
|
||||
@@ -31,7 +24,6 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
@PluginMethod
|
||||
public void configure(PluginCall call) {
|
||||
String userPubkey = call.getString("userPubkey");
|
||||
String notificationStyle = call.getString("notificationStyle", "push");
|
||||
String relayUrlsRaw = null;
|
||||
String enabledKindsRaw = null;
|
||||
String authorsRaw = null;
|
||||
@@ -68,8 +60,7 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
if (userPubkey != null && relayUrlsRaw != null) {
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString("userPubkey", userPubkey)
|
||||
.putString("relayUrls", relayUrlsRaw)
|
||||
.putString("notificationStyle", notificationStyle);
|
||||
.putString("relayUrls", relayUrlsRaw);
|
||||
if (enabledKindsRaw != null) {
|
||||
editor.putString("enabledKinds", enabledKindsRaw);
|
||||
}
|
||||
@@ -79,46 +70,13 @@ public class DittoNotificationPlugin extends Plugin {
|
||||
editor.remove("authors");
|
||||
}
|
||||
editor.apply();
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., style=" + notificationStyle + ", relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
Log.d(TAG, "Configured: pubkey=" + userPubkey.substring(0, 8) + "..., relays=" + relayUrlsRaw + ", kinds=" + enabledKindsRaw + ", authors=" + (authorsRaw != null ? authorsRaw.length() + " chars" : "all"));
|
||||
} else {
|
||||
// Clear config (user logged out)
|
||||
prefs.edit().clear().apply();
|
||||
Log.d(TAG, "Config cleared (user logged out)");
|
||||
}
|
||||
|
||||
// Start or stop the foreground service based on style
|
||||
manageService(notificationStyle, userPubkey != null && relayUrlsRaw != null);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the foreground service when style is "persistent" and config is valid.
|
||||
* Stop it otherwise.
|
||||
*/
|
||||
private void manageService(String style, boolean hasConfig) {
|
||||
Context ctx = getContext();
|
||||
Intent serviceIntent = new Intent(ctx, NotificationRelayService.class);
|
||||
|
||||
if ("persistent".equals(style) && hasConfig) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(serviceIntent);
|
||||
} else {
|
||||
ctx.startService(serviceIntent);
|
||||
}
|
||||
Log.d(TAG, "Started NotificationRelayService (persistent mode)");
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w(TAG, "Could not start foreground service: " + e.getMessage());
|
||||
} else {
|
||||
Log.w(TAG, "Failed to start service", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.stopService(serviceIntent);
|
||||
Log.d(TAG, "Stopped NotificationRelayService (push mode or no config)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +1,42 @@
|
||||
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;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
private static final String PREFS_NAME = "ditto_notification_config";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register native plugins before super.onCreate.
|
||||
// Register the native notification config plugin before super.onCreate
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(SandboxPlugin.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Workaround for @capacitor/keyboard plugin intermittently leaving
|
||||
// the CoordinatorLayout at a fixed pixel height on Android 15+
|
||||
// (API 35+) with edge-to-edge enforced.
|
||||
//
|
||||
// The Keyboard plugin's possiblyResizeChildOfContent() sets the
|
||||
// CoordinatorLayout's LayoutParams.height to a computed pixel value
|
||||
// when the keyboard appears. On keyboard dismiss, the animation
|
||||
// callback resets it to MATCH_PARENT. However, when insets change
|
||||
// without a keyboard animation (permission dialogs, config changes,
|
||||
// edge-to-edge recalculations), the plugin's rootView insets
|
||||
// listener fires with showingKeyboard=true and sets the height,
|
||||
// but no animation runs to reset it — leaving the WebView stuck
|
||||
// at roughly half height.
|
||||
//
|
||||
// Fix: set an OnApplyWindowInsetsListener on the CoordinatorLayout
|
||||
// itself. This fires AFTER the Keyboard plugin's listener on the
|
||||
// rootView (parent dispatches to children). When the IME is not
|
||||
// visible, we force the height back to MATCH_PARENT, overriding
|
||||
// any stale value the plugin may have set in the same dispatch.
|
||||
FrameLayout content = getWindow().getDecorView().findViewById(android.R.id.content);
|
||||
if (content != null && content.getChildCount() > 0) {
|
||||
View child = content.getChildAt(0);
|
||||
// Set the listener on the ContentFrameLayout (parent of the
|
||||
// CoordinatorLayout) so it fires after the Keyboard plugin's
|
||||
// rootView listener but before the SystemBars plugin's listener
|
||||
// on the CoordinatorLayout — avoiding overwriting either one.
|
||||
ViewCompat.setOnApplyWindowInsetsListener(content, (@NonNull View v, @NonNull WindowInsetsCompat insets) -> {
|
||||
boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
|
||||
if (!imeVisible) {
|
||||
ViewGroup.LayoutParams lp = child.getLayoutParams();
|
||||
if (lp.height >= 0) {
|
||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
child.requestLayout();
|
||||
}
|
||||
}
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
// Only start the foreground service if the user has opted into
|
||||
// "persistent" notification style. Default is "push" (no service).
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
String style = prefs.getString("notificationStyle", "push");
|
||||
|
||||
if ("persistent".equals(style)) {
|
||||
try {
|
||||
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
// Start the persistent relay connection service.
|
||||
// On Android 12+ (API 31+) the system may throw
|
||||
// ForegroundServiceStartNotAllowedException if the foreground service
|
||||
// time limit for this type has already been exhausted. We catch it so
|
||||
// the app continues to run normally; the alarm inside the service will
|
||||
// retry at the next scheduled interval.
|
||||
try {
|
||||
Intent serviceIntent = new Intent(this, NotificationRelayService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent);
|
||||
} else {
|
||||
startService(serviceIntent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e instanceof ForegroundServiceStartNotAllowedException) {
|
||||
Log.w("MainActivity", "Could not start NotificationRelayService: " + e.getMessage());
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
*
|
||||
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
|
||||
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
|
||||
* The React code can serve files identically regardless of platform.
|
||||
*/
|
||||
@CapacitorPlugin(name = "SandboxPlugin")
|
||||
public class SandboxPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "SandboxPlugin";
|
||||
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@PluginMethod
|
||||
public void create(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
if (sandboxes.containsKey(sandboxId)) {
|
||||
call.reject("Sandbox already exists: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of the
|
||||
// Capacitor WebView. The parent is a CoordinatorLayout — using
|
||||
// the wrong LayoutParams type causes a ClassCastException when
|
||||
// it intercepts touch events.
|
||||
View capWebView = getBridge().getWebView();
|
||||
ViewGroup parent = (ViewGroup) capWebView.getParent();
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
parent.addView(sandbox.container, params);
|
||||
|
||||
// The spinner is now visible. Navigation is deferred until the
|
||||
// JS layer calls navigate() — this allows the caller to
|
||||
// pre-fetch blobs while the spinner animates.
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void navigate(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void updateFrame(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.container.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void respondToFetch(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
String requestId = call.getString("requestId");
|
||||
if (requestId == null) {
|
||||
call.reject("Missing required parameter: requestId");
|
||||
return;
|
||||
}
|
||||
JSObject response = call.getObject("response");
|
||||
if (response == null) {
|
||||
call.reject("Missing required parameter: response");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
int status = response.optInt("status", 200);
|
||||
String statusText = response.optString("statusText", "OK");
|
||||
String bodyBase64 = response.optString("body", null);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
JSONObject headersObj = response.optJSONObject("headers");
|
||||
if (headersObj != null) {
|
||||
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
headers.put(key, headersObj.optString(key));
|
||||
}
|
||||
}
|
||||
|
||||
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void postMessage(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
JSObject message = call.getObject("message");
|
||||
if (message == null) {
|
||||
call.reject("Missing required parameter: message");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void destroy(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("requestId", requestId);
|
||||
data.put("request", request);
|
||||
notifyListeners("fetch", data);
|
||||
}
|
||||
|
||||
void emitScriptMessage(String sandboxId, JSObject message) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("message", message);
|
||||
notifyListeners("scriptMessage", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single sandboxed WebView instance.
|
||||
*/
|
||||
private static class SandboxInstance {
|
||||
final String id;
|
||||
/** Wrapper layout that holds the WebView and the loading overlay. */
|
||||
final FrameLayout container;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
/** Native spinner overlay, shown while the sandbox content loads. */
|
||||
private ProgressBar spinner;
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
|
||||
this.container = new FrameLayout(plugin.getActivity());
|
||||
this.webView = new WebView(plugin.getActivity());
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
|
||||
// Add JavaScript interface for script->native communication.
|
||||
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
|
||||
|
||||
// Inject the bridge script and intercept requests.
|
||||
webView.setWebViewClient(new SandboxWebViewClient(this));
|
||||
|
||||
// Build the container: WebView fills it, spinner overlays on top.
|
||||
container.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
// Native spinner overlay — uses the Android indeterminate
|
||||
// ProgressBar which animates on the render thread, so it keeps
|
||||
// spinning even when the main/IO threads are busy.
|
||||
spinner = new ProgressBar(plugin.getActivity());
|
||||
spinner.setIndeterminate(true);
|
||||
spinner.getIndeterminateDrawable().setColorFilter(
|
||||
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
|
||||
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
|
||||
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
|
||||
container.addView(spinner, spinnerParams);
|
||||
|
||||
// Dark background behind the spinner.
|
||||
View overlay = new View(plugin.getActivity());
|
||||
overlay.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
// Insert the overlay between the WebView (index 0) and spinner (index 1)
|
||||
// so it covers the WebView but sits behind the spinner.
|
||||
container.addView(overlay, 1, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
}
|
||||
|
||||
/** Remove the native loading overlay. Safe to call multiple times. */
|
||||
void hideSpinner() {
|
||||
if (spinner != null) {
|
||||
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
|
||||
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
|
||||
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
|
||||
spinner = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int dpToPx(SandboxPlugin plugin, int dp) {
|
||||
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
|
||||
return Math.round(dp * density);
|
||||
}
|
||||
|
||||
void postMessageToWebView(String jsonString) {
|
||||
String js = "(function() { " +
|
||||
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
|
||||
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
|
||||
"} " +
|
||||
"})();";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
|
||||
void resolveRequest(String requestId, int status, String statusText,
|
||||
Map<String, String> headers, String bodyBase64) {
|
||||
PendingRequest pending = pendingRequests.remove(requestId);
|
||||
if (pending == null) return;
|
||||
|
||||
byte[] bodyBytes = null;
|
||||
if (bodyBase64 != null && !bodyBase64.equals("null")) {
|
||||
try {
|
||||
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
|
||||
String encoding = contentType.contains("text/") ? "UTF-8" : null;
|
||||
|
||||
InputStream body = bodyBytes != null
|
||||
? new ByteArrayInputStream(bodyBytes)
|
||||
: new ByteArrayInputStream(new byte[0]);
|
||||
|
||||
WebResourceResponse response = new WebResourceResponse(
|
||||
contentType, encoding, status, statusText, headers, body
|
||||
);
|
||||
|
||||
pending.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebViewClient that intercepts all requests and forwards them to JS.
|
||||
*/
|
||||
private static class SandboxWebViewClient extends WebViewClient {
|
||||
private final SandboxInstance sandbox;
|
||||
private boolean bridgeInjected = false;
|
||||
|
||||
SandboxWebViewClient(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
String url = request.getUrl().toString();
|
||||
|
||||
// Only intercept requests to the sandbox domain.
|
||||
if (!url.contains(".sandbox.native")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// Create a pending request with a blocking latch.
|
||||
PendingRequest pending = new PendingRequest();
|
||||
sandbox.pendingRequests.put(requestId, pending);
|
||||
|
||||
// Rewrite URL to include the sandbox ID for the JS handler.
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
|
||||
|
||||
// Serialise the request.
|
||||
JSObject serialisedRequest = new JSObject();
|
||||
serialisedRequest.put("url", rewrittenURL);
|
||||
serialisedRequest.put("method", request.getMethod());
|
||||
|
||||
JSObject headers = new JSObject();
|
||||
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
serialisedRequest.put("headers", headers);
|
||||
serialisedRequest.put("body", JSONObject.NULL);
|
||||
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block until JS responds. Each asset is fetched from a Blossom
|
||||
// server over the network, so we need a generous timeout. The
|
||||
// WebView IO thread pool has ~6 threads; if all are blocked,
|
||||
// subsequent requests queue until a thread frees up.
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Timeout — return error response.
|
||||
sandbox.pendingRequests.remove(requestId);
|
||||
return new WebResourceResponse(
|
||||
"text/plain", "UTF-8", 504,
|
||||
"Gateway Timeout", new HashMap<>(),
|
||||
new ByteArrayInputStream("Request timed out".getBytes())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
|
||||
if (!bridgeInjected) {
|
||||
bridgeInjected = true;
|
||||
view.evaluateJavascript(getBridgeScript(), null);
|
||||
}
|
||||
|
||||
// Remove the native spinner once the first page has finished
|
||||
// loading (all initial resources resolved). This runs on the
|
||||
// main thread, so the removal is safe.
|
||||
sandbox.hideSpinner();
|
||||
}
|
||||
|
||||
private String getBridgeScript() {
|
||||
return "(function() {" +
|
||||
"'use strict';" +
|
||||
"var messageListeners = [];" +
|
||||
"window.__sandboxBridge = {" +
|
||||
" onMessage: function(data) {" +
|
||||
" var event = {" +
|
||||
" data: data," +
|
||||
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
|
||||
" source: window.parent," +
|
||||
" type: 'message'" +
|
||||
" };" +
|
||||
" for (var i = 0; i < messageListeners.length; i++) {" +
|
||||
" try { messageListeners[i](event); } catch(e) {}" +
|
||||
" }" +
|
||||
" }" +
|
||||
"};" +
|
||||
"var origAdd = window.addEventListener;" +
|
||||
"window.addEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
|
||||
" return origAdd.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"var origRemove = window.removeEventListener;" +
|
||||
"window.removeEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message') {" +
|
||||
" var idx = messageListeners.indexOf(fn);" +
|
||||
" if (idx !== -1) messageListeners.splice(idx, 1);" +
|
||||
" }" +
|
||||
" return origRemove.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"if (!window.parent || window.parent === window) window.parent = {};" +
|
||||
"window.parent.postMessage = function(data) {" +
|
||||
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
|
||||
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
|
||||
" }" +
|
||||
"};" +
|
||||
"})();";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript interface exposed to the sandbox WebView.
|
||||
*/
|
||||
private static class SandboxBridge {
|
||||
private final SandboxInstance sandbox;
|
||||
|
||||
SandboxBridge(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void postMessage(String json) {
|
||||
try {
|
||||
JSONObject obj = new JSONObject(json);
|
||||
JSObject jsObj = new JSObject();
|
||||
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
jsObj.put(key, obj.get(key));
|
||||
}
|
||||
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse script message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,11 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
include ':capacitor-local-notifications'
|
||||
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capgo-capacitor-autofill-save-password'
|
||||
project(':capgo-capacitor-autofill-save-password').projectDir = new File('../node_modules/@capgo/capacitor-autofill-save-password/android')
|
||||
|
||||
include ':capacitor-secure-storage-plugin'
|
||||
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
+4
-12
@@ -5,6 +5,8 @@ const config: CapacitorConfig = {
|
||||
appName: 'Ditto',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// Handle deep links from your domain
|
||||
hostname: 'ditto.pub',
|
||||
androidScheme: 'https',
|
||||
iosScheme: 'https'
|
||||
},
|
||||
@@ -15,19 +17,9 @@ const config: CapacitorConfig = {
|
||||
},
|
||||
ios: {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'never',
|
||||
contentInset: 'automatic',
|
||||
scheme: 'Ditto'
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
resizeOnFullScreen: true,
|
||||
},
|
||||
SystemBars: {
|
||||
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||
insetsHandling: 'css',
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{ ignores: ["dist", "android"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -31,10 +28,6 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -70,15 +63,11 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
@@ -156,7 +145,6 @@
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -168,8 +156,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -309,17 +295,15 @@
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -333,17 +317,15 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GZLTTH5DLM;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>webcredentials:ditto.pub</string>
|
||||
<string>webcredentials:ditto.pub?mode=developer</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="DittoBridgeViewController" customModule="App" sceneMemberID="viewController"/>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
class DittoBridgeViewController: CAPBridgeViewController {
|
||||
override func capacitorDidLoad() {
|
||||
super.capacitorDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,7 @@
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Ditto needs access to your photo library to upload images to your posts and profile.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Ditto needs camera access to take photos and videos for your posts.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Ditto needs access to your microphone to record voice messages.</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
|
||||
///
|
||||
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
|
||||
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
|
||||
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
|
||||
/// and forwarded to the JS layer as fetch events — the same protocol
|
||||
/// iframe.diy uses. This lets the existing React code serve files identically.
|
||||
@objc(SandboxPlugin)
|
||||
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "SandboxPlugin"
|
||||
public let jsName = "SandboxPlugin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// Active sandbox instances, keyed by sandbox ID.
|
||||
private var sandboxes: [String: SandboxInstance] = [:]
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@objc func create(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
if sandboxes[sandboxId] != nil {
|
||||
call.reject("Sandbox already exists: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
|
||||
let sandbox = SandboxInstance(
|
||||
id: sandboxId,
|
||||
frame: webViewFrame,
|
||||
plugin: self
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of
|
||||
// the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.containerView)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func navigate(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.navigateToApp()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateFrame(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func respondToFetch(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let requestId = call.getString("requestId") else {
|
||||
call.reject("Missing required parameter: requestId")
|
||||
return
|
||||
}
|
||||
guard let response = call.getObject("response") else {
|
||||
call.reject("Missing required parameter: response")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
sandbox.schemeHandler.resolveRequest(
|
||||
requestId: requestId,
|
||||
status: response["status"] as? Int ?? 200,
|
||||
statusText: response["statusText"] as? String ?? "OK",
|
||||
headers: response["headers"] as? [String: String] ?? [:],
|
||||
bodyBase64: response["body"] as? String
|
||||
)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func postMessage(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let message = call.getObject("message") else {
|
||||
call.reject("Missing required parameter: message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
sandbox.postMessageToWebView(message)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func destroy(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.containerView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Forwarding
|
||||
|
||||
/// Forward a fetch request from the native WebView to JS.
|
||||
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
|
||||
notifyListeners("fetch", data: [
|
||||
"id": sandboxId,
|
||||
"requestId": requestId,
|
||||
"request": request,
|
||||
])
|
||||
}
|
||||
|
||||
/// Forward a script message from the sandbox to JS.
|
||||
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
|
||||
notifyListeners("scriptMessage", data: [
|
||||
"id": sandboxId,
|
||||
"message": message,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
/// Container view that holds the WebView and spinner overlay.
|
||||
let containerView: UIView
|
||||
|
||||
/// Native spinner overlay, removed when the first page finishes loading.
|
||||
private var spinnerOverlay: UIView?
|
||||
|
||||
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
|
||||
self.id = id
|
||||
self.plugin = plugin
|
||||
|
||||
// Each sandbox gets a unique custom URL scheme so that WKWebView
|
||||
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
|
||||
self.customScheme = "sbx-\(id)"
|
||||
|
||||
self.schemeHandler = SandboxSchemeHandler(
|
||||
sandboxId: id,
|
||||
scheme: self.customScheme,
|
||||
plugin: plugin
|
||||
)
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
|
||||
|
||||
// Add a script message handler for communication from injected scripts.
|
||||
let userContentController = WKUserContentController()
|
||||
|
||||
// Inject a bridge script that:
|
||||
// 1. Provides window.parent.postMessage()-like functionality
|
||||
// 2. Routes messages through the native bridge
|
||||
let bridgeScript = WKUserScript(
|
||||
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
|
||||
injectionTime: .atDocumentStart,
|
||||
forMainFrameOnly: false
|
||||
)
|
||||
userContentController.addUserScript(bridgeScript)
|
||||
|
||||
config.userContentController = userContentController
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
// Container view that holds the WebView + spinner overlay.
|
||||
self.containerView = UIView(frame: frame)
|
||||
|
||||
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
|
||||
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
|
||||
self.webView.scrollView.bounces = false
|
||||
self.containerView.addSubview(self.webView)
|
||||
|
||||
// Dark overlay behind the spinner.
|
||||
let overlay = UIView(frame: containerView.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.containerView.addSubview(overlay)
|
||||
|
||||
// Native spinner — uses UIActivityIndicatorView which animates on
|
||||
// the render thread independently of JS/main-thread work.
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.startAnimating()
|
||||
overlay.addSubview(spinner)
|
||||
NSLayoutConstraint.activate([
|
||||
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
||||
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
|
||||
])
|
||||
|
||||
self.spinnerOverlay = overlay
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler and navigation delegate after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
self.webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
/// Navigate the WebView to the sandbox's entry point.
|
||||
func navigateToApp() {
|
||||
let initialURL = URL(string: "\(customScheme)://app/index.html")!
|
||||
webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// Remove the native loading overlay. Safe to call multiple times.
|
||||
func hideSpinner() {
|
||||
spinnerOverlay?.removeFromSuperview()
|
||||
spinnerOverlay = nil
|
||||
}
|
||||
|
||||
/// Post a JSON-RPC message to injected scripts inside the WebView.
|
||||
func postMessageToWebView(_ message: [String: Any]) {
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let js = """
|
||||
(function() {
|
||||
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
|
||||
window.__sandboxBridge.onMessage(\(jsonString));
|
||||
}
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - WKScriptMessageHandler
|
||||
|
||||
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
|
||||
func userContentController(
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
guard message.name == "sandboxBridge",
|
||||
let body = message.body as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
plugin?.emitScriptMessage(sandboxId: id, message: body)
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
/// Remove the spinner overlay once the first page finishes loading.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
hideSpinner()
|
||||
}
|
||||
|
||||
// MARK: - Bridge Script
|
||||
|
||||
/// JavaScript injected at document start that provides:
|
||||
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
|
||||
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
|
||||
/// - `window.addEventListener("message", ...)` support for injected scripts
|
||||
private static func bridgeScript(scheme: String) -> String {
|
||||
return """
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Message listeners registered by injected scripts.
|
||||
var messageListeners = [];
|
||||
|
||||
// Bridge object for native communication.
|
||||
window.__sandboxBridge = {
|
||||
onMessage: function(data) {
|
||||
// Dispatch to all registered message listeners.
|
||||
var event = {
|
||||
data: data,
|
||||
origin: '\(scheme)://app',
|
||||
source: window.parent,
|
||||
type: 'message'
|
||||
};
|
||||
for (var i = 0; i < messageListeners.length; i++) {
|
||||
try {
|
||||
messageListeners[i](event);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override addEventListener to capture "message" listeners.
|
||||
var originalAddEventListener = window.addEventListener;
|
||||
window.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
messageListeners.push(listener);
|
||||
}
|
||||
return originalAddEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
var originalRemoveEventListener = window.removeEventListener;
|
||||
window.removeEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
var idx = messageListeners.indexOf(listener);
|
||||
if (idx !== -1) messageListeners.splice(idx, 1);
|
||||
}
|
||||
return originalRemoveEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
// Emulate window.parent.postMessage for scripts that use it
|
||||
// (e.g. the webxdc bridge script, preview injected script).
|
||||
if (!window.parent || window.parent === window) {
|
||||
window.parent = {};
|
||||
}
|
||||
window.parent.postMessage = function(data, targetOrigin, transfer) {
|
||||
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
|
||||
try {
|
||||
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] postMessage failed:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxSchemeHandler
|
||||
|
||||
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
|
||||
/// URL scheme and forwards them to the JS layer as fetch events.
|
||||
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let sandboxId: String
|
||||
private let scheme: String
|
||||
private weak var plugin: SandboxPlugin?
|
||||
|
||||
/// Pending scheme tasks waiting for a response from JS.
|
||||
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
|
||||
self.sandboxId = sandboxId
|
||||
self.scheme = scheme
|
||||
self.plugin = plugin
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let request = urlSchemeTask.request
|
||||
guard let url = request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let requestId = UUID().uuidString
|
||||
|
||||
lock.lock()
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Serialise the request for the fetch event.
|
||||
// Rewrite the URL so it looks like a normal HTTP URL to the parent
|
||||
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
|
||||
// The JS side only cares about the pathname.
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
headers = allHeaders
|
||||
}
|
||||
|
||||
var bodyBase64: String? = nil
|
||||
if let bodyData = request.httpBody {
|
||||
bodyBase64 = bodyData.base64EncodedString()
|
||||
}
|
||||
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
|
||||
|
||||
let serialisedRequest: [String: Any] = [
|
||||
"url": rewrittenURL,
|
||||
"method": request.httpMethod ?? "GET",
|
||||
"headers": headers,
|
||||
"body": bodyBase64 as Any,
|
||||
]
|
||||
|
||||
plugin?.emitFetchRequest(
|
||||
sandboxId: sandboxId,
|
||||
requestId: requestId,
|
||||
request: serialisedRequest
|
||||
)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
// Remove the task from pending — JS response will be ignored if it arrives later.
|
||||
lock.lock()
|
||||
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
|
||||
if let key = removed?.key {
|
||||
pendingTasks.removeValue(forKey: key)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Called by the plugin when JS responds to a fetch request.
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
statusText: String,
|
||||
headers: [String: String],
|
||||
bodyBase64: String?
|
||||
) {
|
||||
lock.lock()
|
||||
guard let task = pendingTasks.removeValue(forKey: requestId) else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
// Decode the base64 body.
|
||||
var bodyData: Data? = nil
|
||||
if let b64 = bodyBase64 {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
// Build the response.
|
||||
// Use the task's original URL for the response.
|
||||
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
|
||||
DispatchQueue.main.async {
|
||||
task.didReceive(response)
|
||||
if let data = bodyData {
|
||||
task.didReceive(data)
|
||||
}
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all pending tasks (called on destroy).
|
||||
func cancelAll() {
|
||||
lock.lock()
|
||||
let tasks = pendingTasks
|
||||
pendingTasks.removeAll()
|
||||
lock.unlock()
|
||||
|
||||
for (_, task) in tasks {
|
||||
task.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -999,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,9 @@ let package = Package(
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
.package(name: "CapgoCapacitorAutofillSavePassword", path: "../../../node_modules/@capgo/capacitor-autofill-save-password"),
|
||||
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
|
||||
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -28,11 +26,9 @@ 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: "CapgoCapacitorAutofillSavePassword", package: "CapgoCapacitorAutofillSavePassword"),
|
||||
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
|
||||
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+190
-362
@@ -1,20 +1,19 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.4",
|
||||
"version": "2.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.4",
|
||||
"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",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -60,7 +59,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -92,11 +91,10 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
"@unhead/react": "^2.0.10",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -218,66 +216,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==",
|
||||
"version": "7.27.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
|
||||
"integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^8.0.0-rc.3",
|
||||
"@babel/types": "^8.0.0-rc.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"@types/jsesc": "^2.5.0",
|
||||
"@babel/parser": "^7.27.5",
|
||||
"@babel/types": "^7.27.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -428,15 +379,6 @@
|
||||
"@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",
|
||||
@@ -455,21 +397,21 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz",
|
||||
"integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@capgo/capacitor-autofill-save-password": {
|
||||
"version": "8.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@capgo/capacitor-autofill-save-password/-/capacitor-autofill-save-password-8.0.22.tgz",
|
||||
"integrity": "sha512-l6RvtTgdZWDx5fu74QcdV0NLioKmI4PwzCnscpl00ZjxHjecR/yVoB5ufsOYLAY2qyLP3jx9PUpFvEo2rPNHPA==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||
@@ -1847,23 +1789,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
@@ -1875,6 +1811,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/set-array": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -1882,9 +1827,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -2444,22 +2389,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
@@ -2584,9 +2527,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
|
||||
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
|
||||
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
@@ -2613,9 +2556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.123.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
|
||||
"integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
|
||||
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -5448,9 +5391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5465,9 +5408,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5482,9 +5425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5499,9 +5442,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5516,9 +5459,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -5533,9 +5476,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5550,9 +5493,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5567,9 +5510,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -5584,9 +5527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -5601,9 +5544,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5618,9 +5561,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5635,9 +5578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5652,9 +5595,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -5662,18 +5605,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.9.1",
|
||||
"@emnapi/runtime": "1.9.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.2"
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -5688,9 +5629,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5712,9 +5653,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
|
||||
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
@@ -6563,12 +6504,6 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsesc": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
|
||||
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -6960,33 +6895,30 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@unhead/addons": {
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.1.13.tgz",
|
||||
"integrity": "sha512-xiM5ERU68FEuiBCCiPZ1EDkja+kH4hKKot/7dNJufneACtGoAFWnKUcmj/iB9BKjVwgBBF3sFYO3qXjkNFXWxA==",
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/addons/-/addons-2.0.10.tgz",
|
||||
"integrity": "sha512-9+w/m+X5e7CDKXKGTym1N4MpBjrRC89cfl95RDgKwBcFJfQ3pZu50llIjx/j462VqtrNMXddBKcUnfWvQyapuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.3.0",
|
||||
"@rollup/pluginutils": "^5.1.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.0",
|
||||
"ufo": "^1.6.3",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-ast": "^0.16.0"
|
||||
"magic-string": "^0.30.17",
|
||||
"mlly": "^1.7.4",
|
||||
"ufo": "^1.6.1",
|
||||
"unplugin": "^2.3.4",
|
||||
"unplugin-ast": "^0.15.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/harlan-zw"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unhead": "^2.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@unhead/react": {
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.13.tgz",
|
||||
"integrity": "sha512-gC48tNJ0UtbithkiKCc2WUlxbVVk5o171EtruS2w2hQUblfYFHzCPu2hljjT1e0tUHXXqN8EMv7mpxHddMB2sg==",
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@unhead/react/-/react-2.1.12.tgz",
|
||||
"integrity": "sha512-1xXFrxyw29f+kScXfEb0GxjlgtnHxoYau0qpW9k8sgWhQUNnE5gNaH3u+rNhd5IqhyvbdDRJpQ25zoz0HIyGaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unhead": "2.1.13"
|
||||
"unhead": "2.1.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/harlan-zw"
|
||||
@@ -7260,9 +7192,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -7397,68 +7329,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "3.0.0-beta.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz",
|
||||
"integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.0.tgz",
|
||||
"integrity": "sha512-ROM2LlXbZBZVk97crfw8PGDOBzzsJvN2uJCmwswvPUNyfH14eg90mSN3xNqsri1JS1G9cz0VzeDUhxJkTrr4Ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^8.0.0-beta.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"@babel/parser": "^7.27.3",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/astral-regex": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||
@@ -7760,15 +7645,6 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/capacitor-secure-storage-plugin": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
|
||||
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
@@ -10115,15 +9991,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string-ast": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
|
||||
"integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-0.9.1.tgz",
|
||||
"integrity": "sha512-18dv2ZlSSgJ/jDWlZGKfnDJx56ilNlYq9F7NnwuWTErsmYmqJ2TWE4l1o2zlUHBYUGBy3tIhPCC1gxq8M5HkMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"magic-string": "^0.30.19"
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
@@ -11106,15 +10982,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
|
||||
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
"acorn": "^8.14.0",
|
||||
"pathe": "^2.0.1",
|
||||
"pkg-types": "^1.3.0",
|
||||
"ufo": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -12688,14 +12564,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.123.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.13"
|
||||
"@oxc-project/types": "=0.122.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.12"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -12704,27 +12580,27 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.13",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.13",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.13",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
|
||||
"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -13778,9 +13654,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
|
||||
"integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
@@ -13791,9 +13667,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unhead": {
|
||||
"version": "2.1.13",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.13.tgz",
|
||||
"integrity": "sha512-jO9M1sI6b2h/1KpIu4Jeu+ptumLmUKboRRLxys5pYHFeT+lqTzfNHbYUX9bxVDhC1FBszAGuWcUVlmvIPsah8Q==",
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/unhead/-/unhead-2.1.12.tgz",
|
||||
"integrity": "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hookable": "^6.0.1"
|
||||
@@ -13914,85 +13790,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
|
||||
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
|
||||
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.3",
|
||||
"acorn": "^8.14.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.16.0.tgz",
|
||||
"integrity": "sha512-1ow2FlRznoSKE7Fjk2bSxqDsvHyj/O876RqsNlipsM6A+I91t7Mi+jG7tCNNcl3vZx14z4pGXBLSl8KOPrMuFQ==",
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/unplugin-ast/-/unplugin-ast-0.15.0.tgz",
|
||||
"integrity": "sha512-3ReKQUmmYEcNhjoyiwfFuaJU0jkZNcNk8+iLdLVWk73iojVjJLiF/QhnpAFf3O7CJd6bqhWBzNyQ68Udp2fi5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^8.0.0-beta.4",
|
||||
"@babel/parser": "^8.0.0-beta.4",
|
||||
"@babel/types": "^8.0.0-beta.4",
|
||||
"ast-kit": "^3.0.0-beta.1",
|
||||
"magic-string-ast": "^1.0.3",
|
||||
"unplugin": "^3.0.0"
|
||||
"@babel/generator": "^7.27.1",
|
||||
"ast-kit": "^2.0.0",
|
||||
"magic-string-ast": "^0.9.1",
|
||||
"unplugin": "^2.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
"node": ">=20.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/helper-string-parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/parser": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^8.0.0-rc.3"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-ast/node_modules/@babel/types": {
|
||||
"version": "8.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0-rc.3",
|
||||
"@babel/helper-validator-identifier": "^8.0.0-rc.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -14184,16 +14012,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
|
||||
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.13",
|
||||
"rolldown": "1.0.0-rc.12",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@@ -14211,7 +14039,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
@@ -14800,9 +14628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15501,9 +15329,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+5
-8
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.6.5",
|
||||
"version": "2.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
|
||||
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
},
|
||||
@@ -18,10 +17,9 @@
|
||||
"@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",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -67,7 +65,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -99,11 +97,10 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"@unhead/addons": "^2.0.10",
|
||||
"@unhead/react": "^2.0.10",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"GZLTTH5DLM.pub.ditto.app"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
- Apps and games load significantly faster on Android with smarter prefetching and server affinity
|
||||
- Native loading spinners replace HTML-based ones on iOS and Android for a smoother experience
|
||||
|
||||
### Fixed
|
||||
- External API requests on Android no longer fail due to hostname restrictions
|
||||
- iOS App Store compliance issues resolved
|
||||
|
||||
## [2.6.4] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- iCloud Keychain integration on iOS -- your login credentials are now saved and restored automatically across devices
|
||||
|
||||
### Changed
|
||||
- Empty feeds show a friendlier state with a discover button to help you find people to follow
|
||||
- Signup flow simplified -- cleaner profile step with a single Continue button
|
||||
|
||||
### Fixed
|
||||
- Avatar fallback now shows the user's initial instead of a question mark
|
||||
- Android 16+ devices no longer have content hidden behind system bars
|
||||
- Signup dialog background clears properly when switching between light and dark themes
|
||||
- Sticky compose button stays anchored to the bottom even on empty feeds
|
||||
|
||||
## [2.6.3] - 2026-04-10
|
||||
|
||||
### Added
|
||||
- Lightning invoices embedded in posts now render as tappable payment cards
|
||||
- Blobbi companions in the feed reflect their current condition and projected health
|
||||
|
||||
### Changed
|
||||
- Profile headers are cleaner -- lightning addresses and verification badges moved out of the way, and website URLs no longer show a trailing slash
|
||||
- Login credentials are saved to your browser's built-in password manager for easier sign-in across sessions
|
||||
- "Request to Vanish" renamed to "Delete Account" for clarity
|
||||
|
||||
### Fixed
|
||||
- Badge image uploads now show a recommended 1:1 aspect ratio hint so your badges don't get cropped unexpectedly
|
||||
- Security hardening for URLs and styles sourced from the network
|
||||
|
||||
## [2.6.2] - 2026-04-08
|
||||
|
||||
### Added
|
||||
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
|
||||
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
|
||||
- "Write a letter" option on profile menus for a more personal way to reach out
|
||||
- Push vs persistent notification delivery option on Android
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps always open fullscreen for a more immersive experience
|
||||
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
|
||||
- Profile fields now appear inline instead of in a separate right sidebar
|
||||
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
|
||||
|
||||
### Fixed
|
||||
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
|
||||
- File downloads now save directly to Documents on iOS and Android instead of silently failing
|
||||
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
|
||||
- iOS swipe-back navigation works correctly throughout the app
|
||||
- Blobbi companions appear reliably on profiles instead of sometimes going missing
|
||||
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Patch capacitor.config.json to include local (non-SPM) plugin classes.
|
||||
*
|
||||
* `npx cap sync` regenerates the `packageClassList` array from SPM packages
|
||||
* only, so local plugins compiled directly into the app binary (like
|
||||
* SandboxPlugin) are not included. This script appends them after sync so
|
||||
* the Capacitor bridge eagerly registers them at startup.
|
||||
*
|
||||
* Usage: node scripts/patch-cap-config.mjs
|
||||
* Typically run after `npx cap sync`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** Local plugin class names to ensure are registered. */
|
||||
const LOCAL_PLUGINS = ['SandboxPlugin'];
|
||||
|
||||
const platforms = ['ios/App/App', 'android/app/src/main/assets'];
|
||||
|
||||
for (const platform of platforms) {
|
||||
const configPath = resolve(platform, 'capacitor.config.json');
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
} catch {
|
||||
// Platform may not exist or config not yet generated — skip.
|
||||
continue;
|
||||
}
|
||||
|
||||
const classList = new Set(config.packageClassList ?? []);
|
||||
let changed = false;
|
||||
|
||||
for (const plugin of LOCAL_PLUGINS) {
|
||||
if (!classList.has(plugin)) {
|
||||
classList.add(plugin);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
config.packageClassList = [...classList];
|
||||
writeFileSync(configPath, JSON.stringify(config, null, '\t') + '\n');
|
||||
console.log(`Patched ${configPath}: added ${LOCAL_PLUGINS.join(', ')}`);
|
||||
}
|
||||
}
|
||||
+9
-11
@@ -1,7 +1,8 @@
|
||||
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||
// To add new routes, edit the AppRouter.tsx file.
|
||||
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { StatusBar, Style } from "@capacitor/status-bar";
|
||||
import { NostrLoginProvider } from "@nostrify/react/login";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
@@ -23,7 +24,6 @@ import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { secureStorage } from "@/lib/secureStorage";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -149,8 +149,6 @@ const hardcodedConfig: AppConfig = {
|
||||
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
|
||||
savedFeeds: [],
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -183,13 +181,13 @@ export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize system bars for mobile apps.
|
||||
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
|
||||
// setOverlaysWebView / setBackgroundColor no longer work. The new
|
||||
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
|
||||
// Initialize StatusBar for mobile apps
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
|
||||
// SystemBars may not be available on all platforms
|
||||
StatusBar.setStyle({ style: Style.Dark }).catch(() => {
|
||||
// StatusBar may not be available on all platforms
|
||||
});
|
||||
StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {
|
||||
// Ignore errors on unsupported platforms
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -200,7 +198,7 @@ export function App() {
|
||||
<SentryProvider>
|
||||
<PlausibleProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
|
||||
<NostrLoginProvider storageKey="nostr:login">
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
* 4. Settings row — low emphasis toggle (not collapsible)
|
||||
*
|
||||
* Both main sections use lightweight Radix Collapsible wrappers.
|
||||
* Collapsed headers still show summary info (progress / coins).
|
||||
* Collapsed headers still show summary info (progress / XP).
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Coins,
|
||||
Zap,
|
||||
X,
|
||||
Eye,
|
||||
Scroll,
|
||||
@@ -148,6 +148,8 @@ function MissionTypeLegend() {
|
||||
interface DailyMissionsSectionProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
companion?: import('@/blobbi/core/lib/blobbi').BlobbiCompanion | null;
|
||||
updateCompanionEvent?: (event: NostrEvent) => void;
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
@@ -156,6 +158,8 @@ interface DailyMissionsSectionProps {
|
||||
function DailyMissionsSection({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
availableStages,
|
||||
disabled,
|
||||
defaultOpen = true,
|
||||
@@ -171,11 +175,16 @@ function DailyMissionsSection({
|
||||
bonusReward,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
} = useDailyMissions({ availableStages });
|
||||
} = useDailyMissions({
|
||||
availableStages,
|
||||
persistedDailyMissions: profile?.content.dailyMissions,
|
||||
});
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
@@ -194,7 +203,7 @@ function DailyMissionsSection({
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Summary pill — always visible */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
<Zap className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
<span className="tabular-nums">
|
||||
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
|
||||
</span>
|
||||
@@ -215,7 +224,7 @@ function DailyMissionsSection({
|
||||
missions={missions}
|
||||
onClaimReward={(id) => claimReward({ missionId: id })}
|
||||
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
|
||||
todayCoins={todayClaimedReward}
|
||||
todayXp={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Coins,
|
||||
Zap,
|
||||
Gift,
|
||||
Sparkles,
|
||||
Egg,
|
||||
@@ -43,7 +43,7 @@ interface DailyMissionsPanelProps {
|
||||
missions: DailyMission[];
|
||||
onClaimReward: (missionId: string) => void;
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
todayCoins: number;
|
||||
todayXp: number;
|
||||
disabled?: boolean;
|
||||
bonusAvailable?: boolean;
|
||||
bonusClaimed?: boolean;
|
||||
@@ -112,7 +112,7 @@ function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpand
|
||||
</MissionDescription>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
<Zap className="size-3" />
|
||||
+{formatCompactNumber(reward)}
|
||||
</div>
|
||||
|
||||
@@ -124,7 +124,7 @@ function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpand
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
|
||||
>
|
||||
<Trophy className="size-3.5 mr-1.5" />
|
||||
Claim Bonus {formatCompactNumber(reward)} Coins
|
||||
Claim +{formatCompactNumber(reward)} XP
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
@@ -147,7 +147,7 @@ function NoMissionsState() {
|
||||
);
|
||||
}
|
||||
|
||||
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
function AllClaimedState({ todayXp }: { todayXp: number }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Sparkles className="size-5 text-primary/60" />
|
||||
@@ -156,7 +156,7 @@ function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Earned{' '}
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{formatCompactNumber(todayCoins)} coins
|
||||
{formatCompactNumber(todayXp)} XP earned
|
||||
</span>{' '}
|
||||
— come back tomorrow!
|
||||
</p>
|
||||
@@ -189,7 +189,7 @@ export function DailyMissionsPanel({
|
||||
missions,
|
||||
onClaimReward,
|
||||
onRerollMission,
|
||||
todayCoins,
|
||||
todayXp,
|
||||
disabled,
|
||||
bonusAvailable = false,
|
||||
bonusClaimed = false,
|
||||
@@ -205,7 +205,7 @@ export function DailyMissionsPanel({
|
||||
const allRegularClaimed = missions.every((m) => m.claimed);
|
||||
const allDone = allRegularClaimed && bonusClaimed;
|
||||
|
||||
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
|
||||
if (allDone) return <AllClaimedState todayXp={todayXp} />;
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
@@ -251,7 +251,7 @@ export function DailyMissionsPanel({
|
||||
{/* Reward + reroll row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
<Zap className="size-3" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
</span>
|
||||
|
||||
@@ -297,7 +297,7 @@ export function DailyMissionsPanel({
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
|
||||
>
|
||||
<Gift className="size-3.5 mr-1.5" />
|
||||
Claim {formatCompactNumber(mission.reward)} Coins
|
||||
Claim +{formatCompactNumber(mission.reward)} XP
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
/**
|
||||
* useClaimMissionReward - Hook for claiming daily mission rewards
|
||||
*
|
||||
*
|
||||
* Handles:
|
||||
* - Persisting coin rewards to kind 11125 Blobbonaut profile
|
||||
* - Updating localStorage mission state
|
||||
* - Awarding XP to the active companion (Kind 31124)
|
||||
* - Persisting mission claimed state to profile content JSON (Kind 11125)
|
||||
* - Updating the in-memory session store
|
||||
* - Idempotent claiming (prevents double-credit)
|
||||
* - Optimistic cache updates
|
||||
*
|
||||
* Kind 11125 content JSON is the persistent source of truth.
|
||||
* The in-memory session store is updated for immediate UI feedback.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
KIND_BLOBBI_STATE,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
updateDailyMissionsContent,
|
||||
missionToPersistedMission,
|
||||
type PersistedDailyMissions,
|
||||
} from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
getTodayDateString,
|
||||
@@ -27,6 +38,8 @@ import {
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -40,48 +53,32 @@ export const BONUS_MISSION_ID = 'bonus_daily_complete';
|
||||
|
||||
export interface ClaimMissionResult {
|
||||
missionId: string;
|
||||
coinsEarned: number;
|
||||
newTotalCoins: number;
|
||||
xpEarned: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useClaimMissionReward] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
// State is read/written via the in-memory session store in daily-missions.ts.
|
||||
// Kind 11125 content JSON is the persistent source of truth.
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to claim daily mission rewards.
|
||||
*
|
||||
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
|
||||
* ensuring rewards are stored on-chain rather than just in localStorage.
|
||||
* Awards XP to the active companion (Kind 31124) and persists
|
||||
* mission state to the profile content JSON (Kind 11125).
|
||||
*
|
||||
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
|
||||
* @param updateProfileEvent - Callback to update the profile in the query cache
|
||||
* @param currentProfile - The current Blobbonaut profile
|
||||
* @param updateProfileEvent - Optimistic cache update for profile
|
||||
* @param currentCompanion - The active companion to award XP to
|
||||
* @param updateCompanionEvent - Optimistic cache update for companion
|
||||
*/
|
||||
export function useClaimMissionReward(
|
||||
currentProfile: BlobbonautProfile | null,
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
|
||||
currentCompanion?: BlobbiCompanion | null,
|
||||
updateCompanionEvent?: (event: import('@nostrify/nostrify').NostrEvent) => void,
|
||||
) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -96,134 +93,139 @@ export function useClaimMissionReward(
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
// Read current missions state from in-memory session store
|
||||
let missionsState = readDailyMissionsState(user.pubkey);
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
|
||||
const previousXp = missionsState?.totalXpEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousXp);
|
||||
}
|
||||
|
||||
let xpToAward = 0;
|
||||
let updatedState: DailyMissionsState;
|
||||
|
||||
// Handle bonus mission claim
|
||||
if (missionId === BONUS_MISSION_ID) {
|
||||
// Check if bonus is available
|
||||
if (!isBonusMissionAvailable(missionsState!)) {
|
||||
throw new Error('Bonus mission not available yet');
|
||||
}
|
||||
|
||||
// Check if already claimed
|
||||
if (isBonusMissionClaimed(missionsState!)) {
|
||||
throw new Error('Bonus reward already claimed');
|
||||
}
|
||||
|
||||
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
});
|
||||
|
||||
// Publish updated profile event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Update localStorage to mark bonus as claimed
|
||||
const updatedState: DailyMissionsState = {
|
||||
xpToAward = BONUS_MISSION_DEFINITION.reward;
|
||||
updatedState = {
|
||||
...missionsState!,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
totalXpEarned: missionsState!.totalXpEarned + xpToAward,
|
||||
};
|
||||
} else {
|
||||
// Handle regular mission claim
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (!mission) throw new Error('Mission not found');
|
||||
if (mission.claimed) throw new Error('Reward already claimed');
|
||||
if (!mission.completed) throw new Error('Mission not completed yet');
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true, isBonus: true }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
xpToAward = mission.reward;
|
||||
updatedState = {
|
||||
...missionsState!,
|
||||
missions: missionsState!.missions.map(m =>
|
||||
m.id === missionId ? { ...m, claimed: true } : m
|
||||
),
|
||||
totalXpEarned: missionsState!.totalXpEarned + xpToAward,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular mission claim
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (!mission) {
|
||||
throw new Error('Mission not found');
|
||||
}
|
||||
// ── 1. Persist mission state to profile content JSON (Kind 11125) ──
|
||||
|
||||
// Check if already claimed (idempotency check)
|
||||
if (mission.claimed) {
|
||||
throw new Error('Reward already claimed');
|
||||
}
|
||||
|
||||
// Check if mission is completed
|
||||
if (!mission.completed) {
|
||||
throw new Error('Mission not completed yet');
|
||||
}
|
||||
|
||||
const coinsToAdd = mission.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
// Fetch fresh profile to avoid overwriting concurrent changes
|
||||
const freshProfileEvent = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const existingContent = freshProfileEvent?.content ?? '';
|
||||
const existingTags = freshProfileEvent?.tags ?? currentProfile.allTags;
|
||||
|
||||
// Publish updated profile event to kind 11125
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache optimistically
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Now update localStorage to mark mission as claimed
|
||||
const updatedMissions = missionsState!.missions.map(m =>
|
||||
m.id === missionId ? { ...m, claimed: true } : m
|
||||
);
|
||||
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
// Build persisted daily missions
|
||||
const persistedMissions: PersistedDailyMissions = {
|
||||
date: updatedState.date,
|
||||
missions: updatedState.missions.map(missionToPersistedMission),
|
||||
bonusClaimed: updatedState.bonusClaimed ?? false,
|
||||
rerollsRemaining: updatedState.rerollsRemaining ?? 3,
|
||||
totalXpEarned: updatedState.totalXpEarned,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
const updatedContent = updateDailyMissionsContent(existingContent, persistedMissions);
|
||||
|
||||
// Publish updated profile (tags preserved, content updated)
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: updatedContent,
|
||||
tags: existingTags,
|
||||
prev: freshProfileEvent ?? undefined,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ── 2. Award XP to the active companion (Kind 31124) ──
|
||||
|
||||
if (xpToAward > 0 && currentCompanion) {
|
||||
try {
|
||||
// Fetch fresh companion event
|
||||
const freshCompanionEvent = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [currentCompanion.d],
|
||||
});
|
||||
|
||||
if (freshCompanionEvent) {
|
||||
const currentXp = parseInt(
|
||||
freshCompanionEvent.tags.find(([t]) => t === 'experience')?.[1] ?? '0',
|
||||
10,
|
||||
);
|
||||
const newXp = currentXp + xpToAward;
|
||||
|
||||
// Update the experience tag
|
||||
const updatedTags = freshCompanionEvent.tags.map(tag =>
|
||||
tag[0] === 'experience' ? ['experience', String(newXp)] : tag,
|
||||
);
|
||||
|
||||
const companionEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: freshCompanionEvent.content,
|
||||
tags: updatedTags,
|
||||
prev: freshCompanionEvent,
|
||||
});
|
||||
|
||||
updateCompanionEvent?.(companionEvent);
|
||||
}
|
||||
} catch (err) {
|
||||
// XP award failure is non-fatal — mission claim still succeeds
|
||||
console.warn('[useClaimMissionReward] Failed to award XP to companion:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Update in-memory session store for immediate UI feedback ──
|
||||
|
||||
writeDailyMissionsState(user.pubkey, updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true }
|
||||
detail: { missionId, claimed: true, isBonus: missionId === BONUS_MISSION_ID }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
return { missionId, xpEarned: xpToAward };
|
||||
},
|
||||
onSuccess: ({ coinsEarned }) => {
|
||||
// Invalidate profile query to ensure fresh data
|
||||
onSuccess: ({ xpEarned }) => {
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Reward Claimed!',
|
||||
description: `You earned ${coinsEarned} coins.`,
|
||||
description: `${currentCompanion?.name ?? 'Your Blobbi'} earned ${xpEarned} XP`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
/**
|
||||
* useDailyMissions - Hook for managing Blobbi daily missions
|
||||
*
|
||||
* Provides:
|
||||
* - Daily mission state management with localStorage persistence
|
||||
* - Automatic daily reset
|
||||
* - Progress tracking functions
|
||||
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
|
||||
* - Stage-based filtering (only shows missions user can complete)
|
||||
* - Bonus mission tracking
|
||||
*
|
||||
* Note: Reward claiming should be done via useClaimMissionReward hook,
|
||||
* which persists coins to the kind 11125 Blobbonaut profile.
|
||||
*
|
||||
* ── Source-of-Truth Architecture ──────────────────────────────────────────────
|
||||
*
|
||||
* Kind 11125 content JSON is the ONLY persistent source of truth.
|
||||
* This hook maintains an in-memory session cache for instant UI updates.
|
||||
*
|
||||
* Hydration flow:
|
||||
* 1. On mount / account switch, check the in-memory session store.
|
||||
* 2. If empty, hydrate from `persistedDailyMissions` (parsed from the
|
||||
* kind 11125 event that the caller provides).
|
||||
* 3. If kind 11125 also has no data, generate fresh missions for today.
|
||||
* 4. During the session, progress/rerolls update the session store.
|
||||
* 5. Claims persist to kind 11125 via useClaimMissionReward.
|
||||
* 6. On page refresh the session store is empty → re-hydrates from kind 11125.
|
||||
*
|
||||
* localStorage is NOT used. This eliminates cross-account leakage.
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { PersistedDailyMissions } from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import { persistedMissionToMission } from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
@@ -32,6 +39,8 @@ import {
|
||||
BONUS_MISSION_DEFINITION,
|
||||
getRerollsRemaining,
|
||||
MAX_DAILY_REROLLS,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -39,6 +48,13 @@ import {
|
||||
export interface UseDailyMissionsOptions {
|
||||
/** Available Blobbi stages the user has (filters eligible missions) */
|
||||
availableStages?: BlobbiStage[];
|
||||
/**
|
||||
* Persisted daily missions from the kind 11125 profile content.
|
||||
* Pass `profile.content.dailyMissions` here. This is the persistent
|
||||
* source of truth — the hook hydrates from it when the session store
|
||||
* is empty (page refresh, account switch).
|
||||
*/
|
||||
persistedDailyMissions?: PersistedDailyMissions;
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsResult {
|
||||
@@ -52,8 +68,8 @@ export interface UseDailyMissionsResult {
|
||||
totalPotentialReward: number;
|
||||
/** Total claimed reward for today */
|
||||
todayClaimedReward: number;
|
||||
/** Lifetime total coins earned from daily missions */
|
||||
lifetimeCoinsEarned: number;
|
||||
/** Lifetime total XP earned from daily missions */
|
||||
lifetimeXpEarned: number;
|
||||
/** Whether the bonus mission is available (all regular missions completed) */
|
||||
bonusAvailable: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
@@ -70,54 +86,57 @@ export interface UseDailyMissionsResult {
|
||||
forceReset: () => void;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useDailyMissions] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
|
||||
const { availableStages } = options;
|
||||
const { availableStages, persistedDailyMissions } = options;
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Read state directly from localStorage, with a version counter to trigger re-reads
|
||||
// Version counter to trigger re-reads from the in-memory session store
|
||||
// when external mutations (tracker, reroll, claim) update it.
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Read from localStorage on every render when version changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
|
||||
const state = useMemo(() => readMissionsState(), [version]);
|
||||
|
||||
// Wrapper to write state and update version
|
||||
|
||||
// Track the last pubkey we hydrated for, so we re-hydrate on account switch.
|
||||
const hydratedForPubkey = useRef<string | undefined>(undefined);
|
||||
|
||||
// ── Hydration from kind 11125 ──
|
||||
// When the session store is empty for this pubkey (page refresh, first load,
|
||||
// account switch), hydrate from the persisted kind 11125 data.
|
||||
// This runs synchronously in useMemo so the first render has correct data.
|
||||
const state = useMemo(() => {
|
||||
// Reset hydration tracking on account switch
|
||||
if (pubkey !== hydratedForPubkey.current) {
|
||||
hydratedForPubkey.current = pubkey;
|
||||
}
|
||||
|
||||
// Check session store first
|
||||
const sessionState = readDailyMissionsState(pubkey);
|
||||
if (sessionState) return sessionState;
|
||||
|
||||
// Session store empty — try to hydrate from kind 11125
|
||||
if (pubkey && persistedDailyMissions) {
|
||||
const hydrated = hydrateFromPersisted(persistedDailyMissions);
|
||||
if (hydrated) {
|
||||
writeDailyMissionsState(pubkey, hydrated);
|
||||
return hydrated;
|
||||
}
|
||||
}
|
||||
|
||||
// No persisted data — return null (will be handled below)
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version forces re-read from session store
|
||||
}, [version, pubkey, persistedDailyMissions]);
|
||||
|
||||
// Wrapper to write state to session store and bump version for re-render
|
||||
const setState = useCallback((newState: DailyMissionsState) => {
|
||||
writeMissionsState(newState);
|
||||
writeDailyMissionsState(pubkey, newState);
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
}, [pubkey]);
|
||||
|
||||
// Listen for external updates from mutations (reroll, claim, progress tracking)
|
||||
// This re-reads localStorage when other hooks modify it directly
|
||||
useEffect(() => {
|
||||
const handleExternalUpdate = () => {
|
||||
// Bump version to trigger a re-read from localStorage
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -130,33 +149,31 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
|
||||
// Ensure we have valid state for today
|
||||
const currentState = useMemo(() => {
|
||||
// Check if we need to reset for a new day
|
||||
if (needsDailyReset(state)) {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
// Persist the reset state (this will trigger version bump via setState)
|
||||
writeMissionsState(newState);
|
||||
const previousXp = state?.totalXpEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousXp, availableStages);
|
||||
writeDailyMissionsState(pubkey, newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state && state.rerollsRemaining === undefined) {
|
||||
const migratedState = {
|
||||
...state,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
writeMissionsState(migratedState);
|
||||
writeDailyMissionsState(pubkey, migratedState);
|
||||
return migratedState;
|
||||
}
|
||||
|
||||
|
||||
return state!;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, pubkey, stagesKey]);
|
||||
|
||||
// Force reset missions (for testing)
|
||||
const forceReset = () => {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
const previousXp = state?.totalXpEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousXp, availableStages);
|
||||
setState(newState);
|
||||
};
|
||||
|
||||
@@ -170,18 +187,18 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
const noMissionsAvailable = missions.length === 0;
|
||||
const rerollsRemaining = getRerollsRemaining(currentState);
|
||||
const maxRerolls = MAX_DAILY_REROLLS;
|
||||
|
||||
|
||||
// Total potential includes bonus if regular missions exist
|
||||
const basePotentialReward = getTotalPotentialReward(currentState);
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
: 0;
|
||||
|
||||
|
||||
// Today's claimed includes bonus if claimed
|
||||
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
|
||||
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
|
||||
|
||||
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
|
||||
|
||||
const lifetimeXpEarned = currentState.totalXpEarned;
|
||||
|
||||
return {
|
||||
missions,
|
||||
@@ -189,7 +206,7 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
allClaimed,
|
||||
totalPotentialReward,
|
||||
todayClaimedReward,
|
||||
lifetimeCoinsEarned,
|
||||
lifetimeXpEarned,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
@@ -199,3 +216,26 @@ export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDail
|
||||
forceReset,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hydration Helper ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert persisted daily missions (from kind 11125 content) to the
|
||||
* runtime DailyMissionsState used by the hooks.
|
||||
*
|
||||
* Returns null if the persisted data is for a different day (stale).
|
||||
*/
|
||||
function hydrateFromPersisted(persisted: PersistedDailyMissions): DailyMissionsState | null {
|
||||
// Only hydrate if the persisted data is for today
|
||||
if (persisted.date !== getTodayDateString()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
date: persisted.date,
|
||||
missions: persisted.missions.map(persistedMissionToMission),
|
||||
totalXpEarned: persisted.totalXpEarned,
|
||||
bonusClaimed: persisted.bonusClaimed,
|
||||
rerollsRemaining: persisted.rerollsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* useRerollMission - Hook for rerolling daily missions
|
||||
*
|
||||
*
|
||||
* Handles:
|
||||
* - Replacing a mission with a new one from the pool
|
||||
* - Tracking reroll usage (max 3 per day)
|
||||
* - Respecting stage-based mission filtering
|
||||
* - Persisting state to localStorage
|
||||
* - Updating the in-memory session store
|
||||
*
|
||||
* Note: Rerolled state is held in the session store until a claim
|
||||
* persists it to kind 11125. Rerolls that happen without a subsequent
|
||||
* claim are lost on page refresh — this is intentional.
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -14,7 +18,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
@@ -23,6 +26,8 @@ import {
|
||||
rerollMission,
|
||||
canRerollMission,
|
||||
getRerollsRemaining,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -38,37 +43,7 @@ export interface RerollMissionResult {
|
||||
rerollsRemaining: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as DailyMissionsState;
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state.rerollsRemaining === undefined) {
|
||||
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useRerollMission] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
// State is read/written via the in-memory session store in daily-missions.ts.
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -87,13 +62,13 @@ export function useRerollMission() {
|
||||
throw new Error('You must be logged in to reroll missions');
|
||||
}
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
// Read current missions state from in-memory session store
|
||||
let missionsState = readDailyMissionsState(user.pubkey);
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
|
||||
const previousXp = missionsState?.totalXpEarned ?? (missionsState as unknown as { totalCoinsEarned?: number })?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousXp, availableStages);
|
||||
}
|
||||
|
||||
// Check if reroll is allowed
|
||||
@@ -118,8 +93,8 @@ export function useRerollMission() {
|
||||
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
|
||||
}
|
||||
|
||||
// Persist the updated state
|
||||
writeMissionsState(result.state);
|
||||
// Update the in-memory session store
|
||||
writeDailyMissionsState(user.pubkey, result.state);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
|
||||
@@ -1,109 +1,80 @@
|
||||
/**
|
||||
* Daily Mission Tracker - Standalone progress tracking utility
|
||||
*
|
||||
*
|
||||
* This module provides a simple way to track daily mission progress
|
||||
* without requiring React hooks or context. It directly manipulates
|
||||
* localStorage for immediate persistence.
|
||||
*
|
||||
* This approach allows action hooks (which may be called outside of
|
||||
* the daily missions hook context) to record progress.
|
||||
* without requiring React hooks or context. It reads/writes the
|
||||
* in-memory session store for immediate updates.
|
||||
*
|
||||
* ── Source of Truth ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* The in-memory session store (in daily-missions.ts) holds the current
|
||||
* session's mission state. Kind 11125 content JSON is the persistent
|
||||
* source of truth. This tracker updates the session store only — it does
|
||||
* NOT persist to kind 11125 (that happens when rewards are claimed via
|
||||
* useClaimMissionReward).
|
||||
*
|
||||
* Consequence: unclaimed progress is lost on page refresh. This is
|
||||
* intentional — it avoids cross-account leakage and keeps the tracker
|
||||
* simple (no Nostr write path needed).
|
||||
*/
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
updateMissionProgress,
|
||||
readDailyMissionsState,
|
||||
writeDailyMissionsState,
|
||||
} from './daily-missions';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current daily missions state from localStorage
|
||||
*/
|
||||
function readState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the daily missions state to localStorage
|
||||
*/
|
||||
function writeState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[DailyMissionTracker] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid state for today, creating one if necessary
|
||||
*/
|
||||
function ensureCurrentState(pubkey?: string): DailyMissionsState {
|
||||
const current = readState();
|
||||
|
||||
if (needsDailyReset(current)) {
|
||||
const previousCoins = current?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
|
||||
writeState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
return current!;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record progress for a daily mission action.
|
||||
* This function can be called from anywhere (hooks, event handlers, etc.)
|
||||
* and will immediately persist to localStorage.
|
||||
*
|
||||
* and will immediately update the in-memory session store.
|
||||
*
|
||||
* No-ops silently if:
|
||||
* - pubkey is not provided (logged-out users don't track)
|
||||
* - no session state exists yet for this pubkey (hook hasn't hydrated)
|
||||
*
|
||||
* @param action - The action type that was performed
|
||||
* @param count - Number of times the action was performed (default: 1)
|
||||
* @param pubkey - Optional user pubkey for personalized mission selection
|
||||
* @param pubkey - User pubkey (required for account-scoped state)
|
||||
*/
|
||||
export function trackDailyMissionProgress(
|
||||
action: DailyMissionAction,
|
||||
count: number = 1,
|
||||
pubkey?: string
|
||||
): void {
|
||||
const current = ensureCurrentState(pubkey);
|
||||
const current = readDailyMissionsState(pubkey);
|
||||
if (!current) return;
|
||||
|
||||
const updated = updateMissionProgress(current, action, count);
|
||||
writeState(updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render if needed
|
||||
writeDailyMissionsState(pubkey, updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to track multiple actions at once.
|
||||
* Useful when an action should count toward multiple missions.
|
||||
*
|
||||
*
|
||||
* No-ops silently if pubkey is not provided or no session state exists.
|
||||
*
|
||||
* @param actions - Array of actions to track
|
||||
* @param pubkey - Optional user pubkey
|
||||
* @param pubkey - User pubkey (required for account-scoped state)
|
||||
*/
|
||||
export function trackMultipleDailyMissionActions(
|
||||
actions: DailyMissionAction[],
|
||||
pubkey?: string
|
||||
): void {
|
||||
let current = ensureCurrentState(pubkey);
|
||||
|
||||
let current = readDailyMissionsState(pubkey);
|
||||
if (!current) return;
|
||||
|
||||
for (const action of actions) {
|
||||
current = updateMissionProgress(current, action, 1);
|
||||
}
|
||||
|
||||
writeState(current);
|
||||
|
||||
writeDailyMissionsState(pubkey, current);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* This module defines the daily mission pool, selection logic, and types.
|
||||
* Daily missions are separate from hatch/evolve missions and provide
|
||||
* daily engagement loops with coin rewards.
|
||||
* daily engagement loops with XP rewards applied to the active companion.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -40,7 +40,7 @@ export interface DailyMissionDefinition {
|
||||
action: DailyMissionAction;
|
||||
/** Number of times the action must be performed */
|
||||
requiredCount: number;
|
||||
/** Coin reward for completing this mission */
|
||||
/** XP reward for completing this mission (applied to active companion) */
|
||||
reward: number;
|
||||
/** Selection weight (higher = more likely to be selected) */
|
||||
weight: number;
|
||||
@@ -61,15 +61,19 @@ export interface DailyMission extends DailyMissionDefinition {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored state for daily missions (persisted in localStorage)
|
||||
* Stored state for daily missions.
|
||||
*
|
||||
* Source of truth: Kind 11125 profile content JSON (`dailyMissions` section).
|
||||
* During a session, state is held in an in-memory map for instant UI updates.
|
||||
* On page load, the hook hydrates from kind 11125. localStorage is NOT used.
|
||||
*/
|
||||
export interface DailyMissionsState {
|
||||
/** The date string (YYYY-MM-DD) when these missions were generated */
|
||||
date: string;
|
||||
/** The selected missions for this day */
|
||||
missions: DailyMission[];
|
||||
/** Total coins earned from daily missions (lifetime) */
|
||||
totalCoinsEarned: number;
|
||||
/** Total XP earned from daily missions (lifetime) */
|
||||
totalXpEarned: number;
|
||||
/** Whether the bonus mission has been claimed today */
|
||||
bonusClaimed?: boolean;
|
||||
/** Number of rerolls remaining for today (resets daily, max 3) */
|
||||
@@ -81,6 +85,67 @@ export interface DailyMissionsState {
|
||||
/** Maximum number of mission rerolls allowed per day */
|
||||
export const MAX_DAILY_REROLLS = 3;
|
||||
|
||||
// ─── In-Memory Session Store ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* In-memory, pubkey-scoped store for daily missions state.
|
||||
*
|
||||
* ── Source-of-Truth Architecture ──────────────────────────────────────────────
|
||||
*
|
||||
* Kind 11125 content JSON (`dailyMissions` section) is the ONLY persistent
|
||||
* source of truth. This in-memory map is a session cache:
|
||||
*
|
||||
* • On page load / account switch, `useDailyMissions` hydrates this map
|
||||
* from `profile.content.dailyMissions` (parsed from the kind 11125 event).
|
||||
* • During the session, progress/rerolls update this map for instant UI.
|
||||
* • Claims persist to kind 11125 via `updateDailyMissionsContent()`.
|
||||
* • On page refresh the map is empty, so the hook re-hydrates from kind 11125.
|
||||
* • Unclaimed progress (feed counts, etc.) that hasn't been written to
|
||||
* kind 11125 is lost on refresh — this is intentional and preferable to
|
||||
* cross-account leakage through localStorage.
|
||||
*
|
||||
* localStorage is NOT used for daily missions. This eliminates all
|
||||
* cross-account leakage bugs.
|
||||
*/
|
||||
const sessionStore = new Map<string, DailyMissionsState>();
|
||||
|
||||
/**
|
||||
* Read daily missions state from the in-memory session store.
|
||||
*
|
||||
* Returns null if:
|
||||
* - No state exists for this pubkey in the current session
|
||||
* - The pubkey is empty/undefined
|
||||
*/
|
||||
export function readDailyMissionsState(pubkey: string | undefined): DailyMissionsState | null {
|
||||
if (!pubkey) return null;
|
||||
return sessionStore.get(pubkey) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write daily missions state to the in-memory session store.
|
||||
*
|
||||
* This is the ONLY correct way to update session mission state.
|
||||
* No-ops silently if pubkey is empty/undefined (logged-out users
|
||||
* should not have mission state).
|
||||
*
|
||||
* Note: This does NOT persist to kind 11125. Persistence happens
|
||||
* explicitly through `updateDailyMissionsContent()` in the claim
|
||||
* and other write-path hooks.
|
||||
*/
|
||||
export function writeDailyMissionsState(pubkey: string | undefined, state: DailyMissionsState): void {
|
||||
if (!pubkey) return;
|
||||
sessionStore.set(pubkey, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the session store entry for a pubkey.
|
||||
* Used when the hook needs to re-hydrate from kind 11125 data.
|
||||
*/
|
||||
export function clearDailyMissionsState(pubkey: string | undefined): void {
|
||||
if (!pubkey) return;
|
||||
sessionStore.delete(pubkey);
|
||||
}
|
||||
|
||||
// ─── Mission Pool ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -104,7 +169,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Interact with your Blobbi 3 times',
|
||||
action: 'interact',
|
||||
requiredCount: 3,
|
||||
reward: 30,
|
||||
reward: 15,
|
||||
weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -114,7 +179,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Interact with your Blobbi 6 times',
|
||||
action: 'interact',
|
||||
requiredCount: 6,
|
||||
reward: 50,
|
||||
reward: 30,
|
||||
weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -126,7 +191,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Feed your Blobbi once',
|
||||
action: 'feed',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
reward: 10,
|
||||
weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -136,7 +201,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Feed your Blobbi 2 times',
|
||||
action: 'feed',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
reward: 20,
|
||||
weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -146,7 +211,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed',
|
||||
requiredCount: 3,
|
||||
reward: 60,
|
||||
reward: 35,
|
||||
weight: 5,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -158,7 +223,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Put your Blobbi to sleep',
|
||||
action: 'sleep',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
reward: 15,
|
||||
weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -170,7 +235,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Take a polaroid photo of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 1,
|
||||
reward: 55,
|
||||
reward: 25,
|
||||
weight: 4,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -180,7 +245,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Take 2 photos of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
reward: 40,
|
||||
weight: 2,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
@@ -197,7 +262,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Clean your Blobbi once',
|
||||
action: 'clean',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
reward: 10,
|
||||
weight: 10,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
@@ -207,7 +272,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Clean your Blobbi 2 times',
|
||||
action: 'clean',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
reward: 20,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
@@ -219,7 +284,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Sing a song to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
reward: 15,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
@@ -229,7 +294,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Sing 2 songs to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
reward: 25,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
@@ -241,7 +306,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Play a song for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
reward: 15,
|
||||
weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
@@ -251,20 +316,19 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Play 2 songs for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
reward: 25,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Medicine Missions (All stages) ────────────────────────────────────────
|
||||
// Medicine rewards are higher since medicine costs coins to use
|
||||
{
|
||||
id: 'medicine_1',
|
||||
title: 'Health Check',
|
||||
description: 'Give medicine to your Blobbi',
|
||||
action: 'medicine',
|
||||
requiredCount: 1,
|
||||
reward: 60,
|
||||
reward: 20,
|
||||
weight: 5,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
@@ -274,7 +338,7 @@ export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
description: 'Give medicine to your Blobbi 2 times',
|
||||
action: 'medicine',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
reward: 35,
|
||||
weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
@@ -405,14 +469,14 @@ export function createMissionFromDefinition(def: DailyMissionDefinition): DailyM
|
||||
export function createDailyMissionsState(
|
||||
dateString: string,
|
||||
pubkey?: string,
|
||||
previousTotalCoins: number = 0,
|
||||
previousTotalXp: number = 0,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionsState {
|
||||
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
|
||||
return {
|
||||
date: dateString,
|
||||
missions: definitions.map(createMissionFromDefinition),
|
||||
totalCoinsEarned: previousTotalCoins,
|
||||
totalXpEarned: previousTotalXp,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
}
|
||||
@@ -461,8 +525,8 @@ export function updateMissionProgress(
|
||||
export function claimMissionReward(
|
||||
state: DailyMissionsState,
|
||||
missionId: string
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
let coinsEarned = 0;
|
||||
): { state: DailyMissionsState; xpEarned: number } {
|
||||
let xpEarned = 0;
|
||||
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
if (mission.id !== missionId) return mission;
|
||||
@@ -470,7 +534,7 @@ export function claimMissionReward(
|
||||
// Can only claim if completed and not yet claimed
|
||||
if (!mission.completed || mission.claimed) return mission;
|
||||
|
||||
coinsEarned = mission.reward;
|
||||
xpEarned = mission.reward;
|
||||
return {
|
||||
...mission,
|
||||
claimed: true,
|
||||
@@ -481,9 +545,9 @@ export function claimMissionReward(
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
|
||||
totalXpEarned: state.totalXpEarned + xpEarned,
|
||||
},
|
||||
coinsEarned,
|
||||
xpEarned,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -526,10 +590,10 @@ export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
|
||||
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
|
||||
id: 'bonus_daily_complete',
|
||||
title: 'Daily Champion',
|
||||
description: 'Complete all daily missions to claim this bonus reward',
|
||||
description: 'Complete all daily missions to claim this bonus XP',
|
||||
action: 'interact', // Not actually used - bonus is auto-completed
|
||||
requiredCount: 1,
|
||||
reward: 80,
|
||||
reward: 50,
|
||||
weight: 0, // Not part of random selection
|
||||
};
|
||||
|
||||
@@ -553,19 +617,19 @@ export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
|
||||
*/
|
||||
export function claimBonusMissionReward(
|
||||
state: DailyMissionsState
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
): { state: DailyMissionsState; xpEarned: number } {
|
||||
// Can only claim if bonus is available and not yet claimed
|
||||
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
|
||||
return { state, coinsEarned: 0 };
|
||||
return { state, xpEarned: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
|
||||
totalXpEarned: state.totalXpEarned + BONUS_MISSION_DEFINITION.reward,
|
||||
},
|
||||
coinsEarned: BONUS_MISSION_DEFINITION.reward,
|
||||
xpEarned: BONUS_MISSION_DEFINITION.reward,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* Fetches the current companion data from the user's Blobbonaut profile.
|
||||
* This is the data layer - it handles fetching and provides companion data.
|
||||
*
|
||||
* Uses useBlobbisCollection with a targeted dList (single d-tag) for efficiency.
|
||||
* Optimistic updates from mutations propagate across all blobbi-collection
|
||||
* queries (including BlobbiPage's 'all' mode) via updateCompanionEvent.
|
||||
* IMPORTANT: This hook shares the same query cache as BlobbiPage via
|
||||
* useBlobbisCollection. This ensures:
|
||||
* - Immediate reactivity when stats change (optimistic updates)
|
||||
* - Projected decay is applied for accurate visual reactions
|
||||
* - No duplicate queries or stale cache issues
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
@@ -30,14 +32,16 @@ interface UseBlobbiCompanionDataResult {
|
||||
*
|
||||
* Flow:
|
||||
* 1. Use useBlobbonautProfile to get the profile (shared query, reactive)
|
||||
* 2. Build a dList containing just the currentCompanion (targeted fetch)
|
||||
* 3. Use useBlobbisCollection with the dList to get the companion
|
||||
* 2. Build a dList containing just the currentCompanion
|
||||
* 3. Use useBlobbisCollection (shared with BlobbiPage) to get the companion
|
||||
* 4. Apply projected decay for accurate UI reactions
|
||||
* 5. Return the companion data with projected stats
|
||||
*
|
||||
* Reactivity:
|
||||
* - Optimistic updates propagate across all blobbi-collection queries
|
||||
* - Projected decay recalculates every 60 seconds while mounted
|
||||
* - Uses the same query cache as BlobbiPage (blobbi-collection)
|
||||
* - When Blobbi state is updated, optimistic updates flow through immediately
|
||||
* - Projected decay recalculates every 60 seconds
|
||||
* - No separate query or stale cache issues
|
||||
*/
|
||||
export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
// Use the shared profile hook - this ensures reactivity when profile changes
|
||||
|
||||
@@ -188,7 +188,7 @@ export function useBlobbiMigration() {
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
content: profile.event.content,
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
BLOBBI_ECOSYSTEM_NAMESPACE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
@@ -27,90 +26,62 @@ function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
*
|
||||
* Two modes:
|
||||
* - **No dList** (default): Fetches ALL the user's blobbi events by author +
|
||||
* ecosystem namespace tag. This is the authoritative source of truth —
|
||||
* the user authored these events, so we don't need a secondary index.
|
||||
* - **With dList**: Fetches only the specified d-tags. Use this when you only
|
||||
* need a specific subset (e.g. the companion layer needs just one blobbi).
|
||||
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
*
|
||||
* Features:
|
||||
* - Fetches ALL pets by d-tag list (no limit: 1)
|
||||
* - Chunks large d-lists into multiple queries for relay compatibility
|
||||
* - Keeps only the newest event per d-tag
|
||||
* - Returns both a lookup record and array of companions
|
||||
* - Provides invalidation and optimistic update helpers
|
||||
*/
|
||||
export function useBlobbisCollection(dList?: string[] | undefined) {
|
||||
export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Determine the mode: 'all' fetches everything, 'dlist' fetches by specific d-tags
|
||||
const mode = dList === undefined ? 'all' : 'dlist';
|
||||
|
||||
// Create a stable query key based on sorted d-tags (for dlist mode)
|
||||
// Create a stable query key based on sorted d-tags
|
||||
const sortedDList = useMemo(() => {
|
||||
if (mode === 'all' || !dList || dList.length === 0) return null;
|
||||
if (!dList || dList.length === 0) return null;
|
||||
return [...dList].sort();
|
||||
}, [mode, dList]);
|
||||
}, [dList]);
|
||||
|
||||
// Query key segment: 'all' for fetch-all mode, comma-joined d-tags for dlist mode
|
||||
const queryKeySegment = mode === 'all' ? 'all' : (sortedDList?.join(',') ?? '');
|
||||
const queryKeyDTags = sortedDList?.join(',') ?? '';
|
||||
|
||||
// Main query to fetch companions from relays
|
||||
// Main query to fetch all companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeySegment],
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey) {
|
||||
console.log('[useBlobbisCollection] No pubkey, returning empty');
|
||||
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
let allEvents: NostrEvent[];
|
||||
// Log the dList we're about to query
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
|
||||
if (mode === 'all') {
|
||||
// Fetch ALL the user's blobbi events — author is the source of truth
|
||||
// Chunk the d-list for relay compatibility
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
// Query all chunks in parallel
|
||||
const allEvents: NostrEvent[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
|
||||
'#d': chunk,
|
||||
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
|
||||
};
|
||||
|
||||
console.log('[Blobbi] 31124 query filter (all):', JSON.stringify(filter, null, 2));
|
||||
// Log the filter immediately before query
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
allEvents = await nostr.query([filter], { signal });
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
|
||||
console.log('[useBlobbisCollection] Fetch-all returned', allEvents.length, 'events');
|
||||
} else {
|
||||
// Fetch by specific d-tags (for companion layer etc.)
|
||||
if (!sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] Empty dList, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
allEvents = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
};
|
||||
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
}
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
|
||||
@@ -152,7 +123,7 @@ export function useBlobbisCollection(dList?: string[] | undefined) {
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && (mode === 'all' || (!!sortedDList && sortedDList.length > 0)),
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -166,17 +137,17 @@ export function useBlobbisCollection(dList?: string[] | undefined) {
|
||||
// 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) {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeySegment],
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
});
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeySegment]);
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update).
|
||||
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
|
||||
// one matching the current queryKeySegment. This ensures the BlobbiPage cache
|
||||
// and companion layer cache stay in sync (they use different query modes).
|
||||
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
|
||||
// and companion layer cache stay in sync (they use different d-tag lists).
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
@@ -198,14 +169,14 @@ export function useBlobbisCollection(dList?: string[] | undefined) {
|
||||
// If no existing queries matched (first load), set our own query key
|
||||
if (matchingQueries.length === 0) {
|
||||
queryClient.setQueryData<CollectionData>(
|
||||
['blobbi-collection', user.pubkey, queryKeySegment],
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
{
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeySegment]);
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Memoize return values for stability
|
||||
const companionsByD = query.data?.companionsByD ?? {};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { bytesToHex } from '@noble/hashes/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { validateAndRepairBlobbiTags } from './blobbi-tag-schema';
|
||||
import { parseProfileContent } from './blobbonaut-content';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -312,7 +313,7 @@ export interface BlobbonautProfile {
|
||||
name: string | undefined;
|
||||
/** List of owned Blobbi d-tags */
|
||||
has: string[];
|
||||
/** In-game currency balance */
|
||||
/** In-game currency balance (legacy — daily missions now use XP) */
|
||||
coins: number;
|
||||
/** Petting level (interaction counter) */
|
||||
pettingLevel: number;
|
||||
@@ -320,6 +321,8 @@ export interface BlobbonautProfile {
|
||||
storage: StorageItem[];
|
||||
/** All tags preserved for republishing */
|
||||
allTags: string[][];
|
||||
/** Parsed content JSON (daily missions, future fields). Empty object if legacy/empty content. */
|
||||
content: import('./blobbonaut-content').BlobbonautProfileContent;
|
||||
}
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
@@ -971,6 +974,9 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
const pettingLevelValue = parseNumericTag(tags, 'pettingLevel')
|
||||
?? parseNumericTag(tags, 'petting_level')
|
||||
?? 0;
|
||||
|
||||
// Parse structured content JSON (daily missions, future fields)
|
||||
const parsedContent = parseProfileContent(event.content);
|
||||
|
||||
return {
|
||||
event,
|
||||
@@ -984,6 +990,7 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
pettingLevel: pettingLevelValue,
|
||||
storage: parseStorageTags(tags),
|
||||
allTags: tags,
|
||||
content: parsedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1140,6 +1147,8 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
// Progression: derived global level mirrored into a tag for relay queryability
|
||||
'level',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// src/blobbi/core/lib/blobbonaut-content.ts
|
||||
|
||||
/**
|
||||
* Blobbonaut Profile Content JSON — Type definitions, parsing, and serialization.
|
||||
*
|
||||
* Kind 11125 uses a JSON content field alongside tag-based data. The content
|
||||
* field holds independent top-level sections that coexist without interference:
|
||||
*
|
||||
* {
|
||||
* "dailyMissions": { ... },
|
||||
* "progression": { ... },
|
||||
* "<future>": { ... }
|
||||
* }
|
||||
*
|
||||
* ── Source-of-Truth Rules ─────────────────────────────────────────────────────
|
||||
*
|
||||
* • `dailyMissions` is an independent top-level section. It is only modified
|
||||
* by daily mission write paths through `updateDailyMissionsContent()`.
|
||||
*
|
||||
* • `progression` is an independent top-level section. It is only modified
|
||||
* by progression write paths through `updateProgressionContent()` (in
|
||||
* progression.ts). Within `progression`:
|
||||
* – `progression.games.*` is the source of truth for per-game levels/XP.
|
||||
* – `progression.global.level` is derived (sum of all game levels).
|
||||
* – The `["level", "<n>"]` tag is a queryable mirror of the derived level.
|
||||
*
|
||||
* • Unknown top-level keys are always preserved. Future features (inventory,
|
||||
* settings, achievements, etc.) can safely add new top-level sections
|
||||
* without risk of being overwritten.
|
||||
*
|
||||
* ── How to Write Content Safely ───────────────────────────────────────────────
|
||||
*
|
||||
* NEVER manually reconstruct the full content object. Always use one of the
|
||||
* section-specific helpers:
|
||||
*
|
||||
* • `updateDailyMissionsContent(existingContent, missions)` — for daily missions
|
||||
* • `updateProgressionContent(existingContent, update)` — for progression
|
||||
* • `updateContentSection(existingContent, key, value)` — for any section
|
||||
*
|
||||
* These helpers guarantee:
|
||||
* 1. Existing content is parsed safely (invalid JSON → empty object + warning)
|
||||
* 2. Only the targeted section is modified
|
||||
* 3. All sibling sections and unknown keys are preserved
|
||||
* 4. The result is serialized back to a valid JSON string
|
||||
*
|
||||
* Tag-only write paths (shop purchases, onboarding, etc.) that do not modify
|
||||
* the content field should pass `profile.event.content` through unchanged.
|
||||
*
|
||||
* Design principles:
|
||||
* - Content is always valid JSON (or empty string for legacy)
|
||||
* - Unknown fields are preserved during read-modify-write
|
||||
* - Missing fields default gracefully (no crashes on partial data)
|
||||
* - Each top-level key is independently versioned via the field's own shape
|
||||
*/
|
||||
|
||||
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
|
||||
import type { Progression } from './progression';
|
||||
import { parseProgression } from './progression';
|
||||
import { safeParseContent, updateContentSection } from './content-json';
|
||||
|
||||
// Re-export shared utilities so existing importers don't need to change.
|
||||
// The canonical home is content-json.ts; these re-exports keep the public
|
||||
// API backward-compatible.
|
||||
export type { ParsedContentResult } from './content-json';
|
||||
export { safeParseContent, updateContentSection } from './content-json';
|
||||
|
||||
// ─── Daily Missions Persisted Shape ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The daily missions state as persisted in profile content JSON.
|
||||
* This replaces the localStorage-only `DailyMissionsState`.
|
||||
*/
|
||||
export interface PersistedDailyMissions {
|
||||
/** The date these missions were generated (YYYY-MM-DD) */
|
||||
date: string;
|
||||
/** The missions for this day, including progress */
|
||||
missions: PersistedDailyMission[];
|
||||
/** Whether the bonus mission has been claimed today */
|
||||
bonusClaimed: boolean;
|
||||
/** Number of rerolls remaining for today (resets daily) */
|
||||
rerollsRemaining: number;
|
||||
/** Total XP earned from daily missions (lifetime, across all Blobbis) */
|
||||
totalXpEarned: number;
|
||||
/** Timestamp (ms) when this was last modified */
|
||||
lastUpdatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single daily mission as persisted.
|
||||
* Mirrors DailyMission but explicitly typed for serialization.
|
||||
*/
|
||||
export interface PersistedDailyMission {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
requiredCount: number;
|
||||
/** XP reward (was previously coins) */
|
||||
reward: number;
|
||||
weight: number;
|
||||
requiredStages?: string[];
|
||||
currentCount: number;
|
||||
completed: boolean;
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
// ─── Full Profile Content Shape ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The full structured content of a Kind 11125 Blobbonaut Profile event.
|
||||
*
|
||||
* Each field is an independent section. New top-level fields can be added
|
||||
* here as the system grows (inventory, settings, achievements, etc.).
|
||||
*
|
||||
* Unknown fields from the raw JSON are preserved via `RawProfileContent`
|
||||
* during read-modify-write to avoid losing data from future versions.
|
||||
*/
|
||||
export interface BlobbonautProfileContent {
|
||||
/** Daily missions state. Undefined if never migrated. */
|
||||
dailyMissions?: PersistedDailyMissions;
|
||||
/** Progression system (global level + per-game levels/XP/unlocks). Undefined if not yet initialized. */
|
||||
progression?: Progression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal representation that also carries unknown fields for safe merging.
|
||||
* Every parse and merge operation works on this type to ensure forward
|
||||
* compatibility — keys we don't recognize are never dropped.
|
||||
*/
|
||||
interface RawProfileContent extends BlobbonautProfileContent {
|
||||
/** Captures any fields we don't recognize, for forward compatibility */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Typed Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the content field of a Kind 11125 event into structured, typed data.
|
||||
*
|
||||
* - Empty string or invalid JSON returns empty object (no dailyMissions,
|
||||
* no progression).
|
||||
* - Malformed sections are silently dropped (not propagated as corrupt data).
|
||||
* - Unknown top-level fields are preserved in the return value for forward
|
||||
* compatibility.
|
||||
*
|
||||
* Use this when you need typed access to content fields (e.g. reading
|
||||
* `profile.content.dailyMissions`). For write operations, use the
|
||||
* section-specific update helpers instead.
|
||||
*/
|
||||
export function parseProfileContent(content: string): RawProfileContent {
|
||||
const { data } = safeParseContent(content);
|
||||
|
||||
// Start with all keys (including unknown ones)
|
||||
const result: RawProfileContent = { ...data };
|
||||
|
||||
// ── Validate dailyMissions ──
|
||||
if (data.dailyMissions) {
|
||||
const dm = data.dailyMissions;
|
||||
if (
|
||||
typeof dm === 'object' &&
|
||||
dm !== null &&
|
||||
!Array.isArray(dm) &&
|
||||
typeof (dm as Record<string, unknown>).date === 'string' &&
|
||||
Array.isArray((dm as Record<string, unknown>).missions)
|
||||
) {
|
||||
const dmObj = dm as Record<string, unknown>;
|
||||
result.dailyMissions = {
|
||||
date: dmObj.date as string,
|
||||
missions: (dmObj.missions as unknown[]).filter(isValidPersistedMission),
|
||||
bonusClaimed: dmObj.bonusClaimed === true,
|
||||
rerollsRemaining: typeof dmObj.rerollsRemaining === 'number' ? dmObj.rerollsRemaining : 3,
|
||||
totalXpEarned: typeof dmObj.totalXpEarned === 'number' ? dmObj.totalXpEarned : 0,
|
||||
lastUpdatedAt: typeof dmObj.lastUpdatedAt === 'number' ? dmObj.lastUpdatedAt : 0,
|
||||
};
|
||||
} else {
|
||||
// Malformed — drop it rather than persisting corrupt data
|
||||
delete result.dailyMissions;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate progression ──
|
||||
// parseProgression returns undefined for malformed data, which safely
|
||||
// removes the key rather than persisting corrupt structures.
|
||||
if (data.progression !== undefined) {
|
||||
const parsed = parseProgression(data.progression);
|
||||
if (parsed) {
|
||||
result.progression = parsed;
|
||||
} else {
|
||||
delete result.progression;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single persisted mission has the minimum required fields.
|
||||
*/
|
||||
function isValidPersistedMission(m: unknown): m is PersistedDailyMission {
|
||||
if (typeof m !== 'object' || m === null) return false;
|
||||
const obj = m as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.id === 'string' &&
|
||||
typeof obj.action === 'string' &&
|
||||
typeof obj.requiredCount === 'number' &&
|
||||
typeof obj.reward === 'number' &&
|
||||
typeof obj.currentCount === 'number' &&
|
||||
typeof obj.completed === 'boolean' &&
|
||||
typeof obj.claimed === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Missions Content Update ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the `dailyMissions` section inside a kind 11125 content string.
|
||||
*
|
||||
* This is the **standard entry point** for any code path that needs to
|
||||
* persist daily mission state. It:
|
||||
*
|
||||
* 1. Parses the existing content safely (empty/invalid → empty object)
|
||||
* 2. Replaces only the `dailyMissions` key
|
||||
* 3. Preserves `progression`, unknown keys, and all other sibling sections
|
||||
* 4. Returns the serialized content string
|
||||
*
|
||||
* ── Why this function should be the standard path ──
|
||||
*
|
||||
* Every kind 11125 content write that touches `dailyMissions` should flow
|
||||
* through `updateDailyMissionsContent`. This guarantees:
|
||||
* - `progression` is never overwritten
|
||||
* - Unknown top-level keys are never dropped
|
||||
* - Future sections (inventory, settings, achievements) are safe
|
||||
* - The merge is always conservative and section-scoped
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param dailyMissions - The complete daily missions state to persist
|
||||
* @returns The serialized content string with dailyMissions updated
|
||||
*/
|
||||
export function updateDailyMissionsContent(
|
||||
existingContent: string,
|
||||
dailyMissions: PersistedDailyMissions,
|
||||
): string {
|
||||
return updateContentSection(existingContent, 'dailyMissions', dailyMissions);
|
||||
}
|
||||
|
||||
// ─── Legacy Merge (deprecated) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize profile content to a JSON string for the event content field.
|
||||
*
|
||||
* @deprecated Use the section-specific helpers instead:
|
||||
* - `updateDailyMissionsContent(existingContent, missions)` for daily missions
|
||||
* - `updateProgressionContent(existingContent, update)` for progression (from progression.ts)
|
||||
* - `updateContentSection(existingContent, key, value)` for generic sections
|
||||
*
|
||||
* This function performs a shallow merge which is safe for flat sections
|
||||
* like `dailyMissions` but NOT safe for nested sections like `progression`
|
||||
* (which requires deep merging to avoid dropping sibling game entries).
|
||||
*
|
||||
* Kept for backward compatibility but should not be used in new code.
|
||||
*/
|
||||
export function mergeProfileContent(
|
||||
existingContent: string,
|
||||
updates: Partial<BlobbonautProfileContent>,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
// Shallow merge — safe for flat sections, not for nested ones.
|
||||
const merged: Record<string, unknown> = {
|
||||
...data,
|
||||
...updates,
|
||||
};
|
||||
|
||||
return JSON.stringify(merged);
|
||||
}
|
||||
|
||||
// ─── Conversion helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a DailyMission (from the runtime type) to persisted form.
|
||||
* These are nearly identical but this keeps the boundary explicit.
|
||||
*/
|
||||
export function missionToPersistedMission(m: DailyMission): PersistedDailyMission {
|
||||
return {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
action: m.action,
|
||||
requiredCount: m.requiredCount,
|
||||
reward: m.reward,
|
||||
weight: m.weight,
|
||||
requiredStages: m.requiredStages,
|
||||
currentCount: m.currentCount,
|
||||
completed: m.completed,
|
||||
claimed: m.claimed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PersistedDailyMission back to the runtime DailyMission type.
|
||||
*/
|
||||
export function persistedMissionToMission(p: PersistedDailyMission): DailyMission {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title ?? p.id,
|
||||
description: p.description ?? '',
|
||||
action: p.action as DailyMission['action'],
|
||||
requiredCount: p.requiredCount,
|
||||
reward: p.reward,
|
||||
weight: p.weight ?? 1,
|
||||
requiredStages: p.requiredStages as DailyMission['requiredStages'],
|
||||
currentCount: p.currentCount,
|
||||
completed: p.completed,
|
||||
claimed: p.claimed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
// src/blobbi/core/lib/content-coexistence.test.ts
|
||||
|
||||
/**
|
||||
* Coexistence tests for the kind 11125 content system.
|
||||
*
|
||||
* These tests verify the critical guarantee that independent content sections
|
||||
* (dailyMissions, progression, unknown/future keys) can be updated without
|
||||
* interfering with each other. Every test here represents an invariant that
|
||||
* must never be broken.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { safeParseContent, updateContentSection } from './content-json';
|
||||
import {
|
||||
parseProfileContent,
|
||||
updateDailyMissionsContent,
|
||||
type PersistedDailyMissions,
|
||||
} from './blobbonaut-content';
|
||||
import { updateProgressionContent, upsertLevelTag } from './progression';
|
||||
|
||||
// ─── Test Fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_DAILY_MISSIONS: PersistedDailyMissions = {
|
||||
date: '2026-04-06',
|
||||
missions: [
|
||||
{
|
||||
id: 'feed_3',
|
||||
title: 'Feed your Blobbi',
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed',
|
||||
requiredCount: 3,
|
||||
reward: 50,
|
||||
weight: 1,
|
||||
currentCount: 2,
|
||||
completed: false,
|
||||
claimed: false,
|
||||
},
|
||||
],
|
||||
bonusClaimed: false,
|
||||
rerollsRemaining: 2,
|
||||
totalXpEarned: 150,
|
||||
lastUpdatedAt: 1712400000000,
|
||||
};
|
||||
|
||||
const SAMPLE_PROGRESSION_JSON = {
|
||||
global: { level: 5, xp: 0 },
|
||||
games: {
|
||||
blobbi: {
|
||||
level: 3,
|
||||
xp: 250,
|
||||
unlocks: { maxBlobbis: 2, realInventoryEnabled: true },
|
||||
},
|
||||
farm: { level: 2, xp: 100 },
|
||||
},
|
||||
};
|
||||
|
||||
const SAMPLE_UNKNOWN_SECTION = { achievements: ['first_hatch', 'level_5'] };
|
||||
|
||||
/** Build a full content string with all sections present. */
|
||||
function buildFullContent(): string {
|
||||
return JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
futureFeature: SAMPLE_UNKNOWN_SECTION,
|
||||
settings: { theme: 'dark', language: 'en' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Progression ↔ DailyMissions Coexistence ──────────────────────────────────
|
||||
|
||||
describe('progression and dailyMissions coexistence', () => {
|
||||
it('updating progression preserves dailyMissions exactly', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 4, xp: 300 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
});
|
||||
|
||||
it('updating dailyMissions preserves progression exactly', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const updatedMissions: PersistedDailyMissions = {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
totalXpEarned: 200,
|
||||
lastUpdatedAt: 9999999999999,
|
||||
};
|
||||
|
||||
const content = updateDailyMissionsContent(existing, updatedMissions);
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// Progression must be untouched
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
// dailyMissions must reflect the update
|
||||
expect(parsed.dailyMissions.totalXpEarned).toBe(200);
|
||||
});
|
||||
|
||||
it('updating progression then dailyMissions preserves both updates', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
// First: update progression
|
||||
const { content: afterProgression } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 10 } },
|
||||
});
|
||||
|
||||
// Second: update daily missions on top of the progression-updated content
|
||||
const updatedMissions: PersistedDailyMissions = {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
bonusClaimed: true,
|
||||
};
|
||||
const afterBoth = updateDailyMissionsContent(afterProgression, updatedMissions);
|
||||
|
||||
const parsed = JSON.parse(afterBoth);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(10);
|
||||
expect(parsed.progression.global.level).toBe(12); // 10 + 2 (farm)
|
||||
expect(parsed.dailyMissions.bonusClaimed).toBe(true);
|
||||
});
|
||||
|
||||
it('updating dailyMissions then progression preserves both updates', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
// First: update daily missions
|
||||
const updatedMissions: PersistedDailyMissions = {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
rerollsRemaining: 0,
|
||||
};
|
||||
const afterMissions = updateDailyMissionsContent(existing, updatedMissions);
|
||||
|
||||
// Second: update progression on top of the missions-updated content
|
||||
const { content: afterBoth } = updateProgressionContent(afterMissions, {
|
||||
games: { blobbi: { xp: 500 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(afterBoth);
|
||||
expect(parsed.dailyMissions.rerollsRemaining).toBe(0);
|
||||
expect(parsed.progression.games.blobbi.xp).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sibling Game Preservation ────────────────────────────────────────────────
|
||||
|
||||
describe('progression.games sibling preservation', () => {
|
||||
it('updating blobbi preserves sibling future games', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 7 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.farm).toEqual({ level: 2, xp: 100 });
|
||||
expect(parsed.progression.games.blobbi.level).toBe(7);
|
||||
});
|
||||
|
||||
it('adding a new game preserves all existing games', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { racing: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(3);
|
||||
expect(parsed.progression.games.farm).toEqual({ level: 2, xp: 100 });
|
||||
expect(parsed.progression.games.racing.level).toBe(1);
|
||||
expect(parsed.progression.global.level).toBe(6); // 3 + 2 + 1
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Unknown Key Preservation ─────────────────────────────────────────────────
|
||||
|
||||
describe('unknown top-level key preservation', () => {
|
||||
it('updating progression preserves unknown keys', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { xp: 999 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.futureFeature).toEqual(SAMPLE_UNKNOWN_SECTION);
|
||||
expect(parsed.settings).toEqual({ theme: 'dark', language: 'en' });
|
||||
});
|
||||
|
||||
it('updating dailyMissions preserves unknown keys', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const content = updateDailyMissionsContent(existing, {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
totalXpEarned: 500,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.futureFeature).toEqual(SAMPLE_UNKNOWN_SECTION);
|
||||
expect(parsed.settings).toEqual({ theme: 'dark', language: 'en' });
|
||||
});
|
||||
|
||||
it('updateContentSection preserves all sibling keys', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const content = updateContentSection(existing, 'inventory', { items: ['potion'] });
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
expect(parsed.futureFeature).toEqual(SAMPLE_UNKNOWN_SECTION);
|
||||
expect(parsed.inventory).toEqual({ items: ['potion'] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Level Tag Isolation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('level tag does not affect unrelated tags', () => {
|
||||
it('upsertLevelTag preserves all other tags', () => {
|
||||
const tags = [
|
||||
['d', 'blobbonaut-abc123'],
|
||||
['b', 'blobbi:ecosystem:v1'],
|
||||
['name', 'TestPlayer'],
|
||||
['coins', '500'],
|
||||
['has', 'blobbi-001'],
|
||||
['has', 'blobbi-002'],
|
||||
['storage', 'potion:3'],
|
||||
['current_companion', 'blobbi-001'],
|
||||
['blobbi_onboarding_done', 'true'],
|
||||
];
|
||||
|
||||
const result = upsertLevelTag(tags, 5);
|
||||
|
||||
// All original tags preserved in order
|
||||
expect(result.slice(0, 9)).toEqual(tags);
|
||||
// Level appended
|
||||
expect(result[9]).toEqual(['level', '5']);
|
||||
// Total length: original + 1
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('updating existing level tag does not change tag order', () => {
|
||||
const tags = [
|
||||
['d', 'blobbonaut-abc123'],
|
||||
['level', '3'],
|
||||
['name', 'TestPlayer'],
|
||||
['coins', '500'],
|
||||
];
|
||||
|
||||
const result = upsertLevelTag(tags, 7);
|
||||
|
||||
expect(result).toEqual([
|
||||
['d', 'blobbonaut-abc123'],
|
||||
['level', '7'],
|
||||
['name', 'TestPlayer'],
|
||||
['coins', '500'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('level tag always mirrors derived global level', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
// Update Blobbi level from 3 to 8
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 8 } },
|
||||
});
|
||||
|
||||
// Global = blobbi(8) + farm(2) = 10
|
||||
expect(globalLevel).toBe(10);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(10);
|
||||
|
||||
// upsertLevelTag mirrors this
|
||||
const tags = upsertLevelTag([['d', 'test']], globalLevel);
|
||||
expect(tags).toContainEqual(['level', '10']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Malformed Data Safety ────────────────────────────────────────────────────
|
||||
|
||||
describe('malformed progression is safely dropped', () => {
|
||||
it('parseProfileContent drops malformed progression (no games key)', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: { global: { level: 5, xp: 0 } }, // Missing 'games'
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeDefined();
|
||||
expect(parsed.progression).toBeUndefined(); // Dropped
|
||||
});
|
||||
|
||||
it('parseProfileContent drops non-object progression', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: 'not-an-object',
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeDefined();
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateProgressionContent still works after malformed progression', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: 42, // Malformed
|
||||
});
|
||||
|
||||
const { content: updated, globalLevel } = updateProgressionContent(content, {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(updated);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed dailyMissions is safely dropped', () => {
|
||||
it('parseProfileContent drops malformed dailyMissions (missing date)', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: { missions: [], bonusClaimed: false }, // Missing 'date'
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeUndefined(); // Dropped
|
||||
expect(parsed.progression).toBeDefined();
|
||||
});
|
||||
|
||||
it('parseProfileContent drops non-object dailyMissions', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: 'corrupted',
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
expect(parsed.progression).toBeDefined();
|
||||
});
|
||||
|
||||
it('updateDailyMissionsContent replaces malformed dailyMissions', () => {
|
||||
const content = JSON.stringify({
|
||||
dailyMissions: null, // Malformed
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
});
|
||||
|
||||
const updated = updateDailyMissionsContent(content, SAMPLE_DAILY_MISSIONS);
|
||||
const parsed = JSON.parse(updated);
|
||||
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Invalid JSON Content ─────────────────────────────────────────────────────
|
||||
|
||||
describe('invalid JSON content does not crash', () => {
|
||||
it('safeParseContent returns parseOk: false for invalid JSON', () => {
|
||||
const result = safeParseContent('not valid json {{{}}}');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: false for array JSON', () => {
|
||||
const result = safeParseContent('[1, 2, 3]');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: false for string JSON', () => {
|
||||
const result = safeParseContent('"just a string"');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: true for empty string', () => {
|
||||
const result = safeParseContent('');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: true for whitespace-only', () => {
|
||||
const result = safeParseContent(' \n\t ');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('safeParseContent returns parseOk: true for valid JSON object', () => {
|
||||
const result = safeParseContent('{"hello": "world"}');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({ hello: 'world' });
|
||||
});
|
||||
|
||||
it('updateProgressionContent works on invalid JSON', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('{{bad}}', {
|
||||
games: { blobbi: { level: 2, xp: 50 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(2);
|
||||
expect(globalLevel).toBe(2);
|
||||
// No dailyMissions because input was corrupt
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateDailyMissionsContent works on invalid JSON', () => {
|
||||
const content = updateDailyMissionsContent('not json!!!', SAMPLE_DAILY_MISSIONS);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
// No progression because input was corrupt
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parseProfileContent returns empty object for invalid JSON', () => {
|
||||
const parsed = parseProfileContent('corrupted {{{');
|
||||
expect(parsed).toEqual({});
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Global Level Derivation ──────────────────────────────────────────────────
|
||||
|
||||
describe('global.level is always derived from games.*', () => {
|
||||
it('global.level equals sum of game levels after update', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 10 } },
|
||||
});
|
||||
|
||||
// blobbi(10) + farm(2) = 12
|
||||
expect(globalLevel).toBe(12);
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(12);
|
||||
});
|
||||
|
||||
it('global.level is re-derived even if caller passes a value', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
global: { level: 999 }, // Should be ignored
|
||||
games: { blobbi: { level: 1 } },
|
||||
});
|
||||
|
||||
// blobbi(1) + farm(2) = 3
|
||||
expect(globalLevel).toBe(3);
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(3);
|
||||
});
|
||||
|
||||
it('global.level reflects new game added', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
const { globalLevel } = updateProgressionContent(existing, {
|
||||
games: { racing: { level: 5, xp: 0 } },
|
||||
});
|
||||
|
||||
// blobbi(3) + farm(2) + racing(5) = 10
|
||||
expect(globalLevel).toBe(10);
|
||||
});
|
||||
|
||||
it('parseProfileContent re-derives global.level from stored data', () => {
|
||||
// Stored content has wrong global level
|
||||
const content = JSON.stringify({
|
||||
progression: {
|
||||
global: { level: 999, xp: 0 }, // Wrong!
|
||||
games: {
|
||||
blobbi: { level: 2, xp: 0, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseProfileContent(content);
|
||||
// Re-derived: only blobbi at level 2
|
||||
expect(parsed.progression!.global.level).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scalability for Future Sections ──────────────────────────────────────────
|
||||
|
||||
describe('scalability for future sections', () => {
|
||||
it('updateContentSection can add arbitrary new sections', () => {
|
||||
const existing = buildFullContent();
|
||||
|
||||
let content = updateContentSection(existing, 'inventory', { slots: 10, items: [] });
|
||||
content = updateContentSection(content, 'achievements', ['first_hatch']);
|
||||
content = updateContentSection(content, 'settings', { theme: 'light' });
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.inventory).toEqual({ slots: 10, items: [] });
|
||||
expect(parsed.achievements).toEqual(['first_hatch']);
|
||||
expect(parsed.settings).toEqual({ theme: 'light' });
|
||||
// Original sections preserved
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toEqual(SAMPLE_PROGRESSION_JSON);
|
||||
});
|
||||
|
||||
it('section-specific helpers preserve arbitrary new sections', () => {
|
||||
// Start with custom sections
|
||||
const existing = JSON.stringify({
|
||||
dailyMissions: SAMPLE_DAILY_MISSIONS,
|
||||
progression: SAMPLE_PROGRESSION_JSON,
|
||||
inventory: { slots: 10, items: ['potion'] },
|
||||
achievements: ['first_hatch', 'level_5'],
|
||||
leaderboard: { rank: 42 },
|
||||
});
|
||||
|
||||
// Update progression
|
||||
const { content: afterProg } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { xp: 999 } },
|
||||
});
|
||||
|
||||
// Update daily missions
|
||||
const afterMissions = updateDailyMissionsContent(afterProg, {
|
||||
...SAMPLE_DAILY_MISSIONS,
|
||||
totalXpEarned: 9999,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(afterMissions);
|
||||
expect(parsed.inventory).toEqual({ slots: 10, items: ['potion'] });
|
||||
expect(parsed.achievements).toEqual(['first_hatch', 'level_5']);
|
||||
expect(parsed.leaderboard).toEqual({ rank: 42 });
|
||||
expect(parsed.progression.games.blobbi.xp).toBe(999);
|
||||
expect(parsed.dailyMissions.totalXpEarned).toBe(9999);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Empty / Legacy Content ───────────────────────────────────────────────────
|
||||
|
||||
describe('empty and legacy content handling', () => {
|
||||
it('works on empty string content (legacy profiles)', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('', {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updateDailyMissionsContent works on empty string', () => {
|
||||
const content = updateDailyMissionsContent('', SAMPLE_DAILY_MISSIONS);
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.progression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parseProfileContent works on empty string', () => {
|
||||
const parsed = parseProfileContent('');
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
|
||||
it('sequential operations from empty build up correctly', () => {
|
||||
// Start from empty (legacy profile)
|
||||
let content = '';
|
||||
|
||||
// Add progression
|
||||
const { content: c1 } = updateProgressionContent(content, {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
content = c1;
|
||||
|
||||
// Add daily missions
|
||||
content = updateDailyMissionsContent(content, SAMPLE_DAILY_MISSIONS);
|
||||
|
||||
// Add generic section
|
||||
content = updateContentSection(content, 'settings', { theme: 'dark' });
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(parsed.dailyMissions).toEqual(SAMPLE_DAILY_MISSIONS);
|
||||
expect(parsed.settings).toEqual({ theme: 'dark' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// src/blobbi/core/lib/content-json.test.ts
|
||||
|
||||
/**
|
||||
* Tests for the low-level content JSON utilities.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { safeParseContent, updateContentSection } from './content-json';
|
||||
|
||||
// ─── safeParseContent ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('safeParseContent', () => {
|
||||
it('returns parseOk: true with empty data for empty string', () => {
|
||||
const result = safeParseContent('');
|
||||
expect(result).toEqual({ parseOk: true, data: {} });
|
||||
});
|
||||
|
||||
it('returns parseOk: true with empty data for whitespace', () => {
|
||||
const result = safeParseContent(' \n\t ');
|
||||
expect(result).toEqual({ parseOk: true, data: {} });
|
||||
});
|
||||
|
||||
it('returns parseOk: true for valid JSON object', () => {
|
||||
const result = safeParseContent('{"key": "value", "num": 42}');
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({ key: 'value', num: 42 });
|
||||
});
|
||||
|
||||
it('preserves all keys including nested objects', () => {
|
||||
const input = JSON.stringify({
|
||||
a: 1,
|
||||
b: { nested: true },
|
||||
c: [1, 2, 3],
|
||||
d: null,
|
||||
});
|
||||
const result = safeParseContent(input);
|
||||
expect(result.parseOk).toBe(true);
|
||||
expect(result.data).toEqual({ a: 1, b: { nested: true }, c: [1, 2, 3], d: null });
|
||||
});
|
||||
|
||||
it('returns parseOk: false for invalid JSON', () => {
|
||||
const result = safeParseContent('not json');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON array', () => {
|
||||
const result = safeParseContent('[1, 2, 3]');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON string', () => {
|
||||
const result = safeParseContent('"hello"');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON number', () => {
|
||||
const result = safeParseContent('42');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON boolean', () => {
|
||||
const result = safeParseContent('true');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parseOk: false for JSON null', () => {
|
||||
const result = safeParseContent('null');
|
||||
expect(result.parseOk).toBe(false);
|
||||
expect(result.data).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateContentSection ─────────────────────────────────────────────────────
|
||||
|
||||
describe('updateContentSection', () => {
|
||||
it('adds a new section to empty content', () => {
|
||||
const result = updateContentSection('', 'newSection', { value: 42 });
|
||||
expect(JSON.parse(result)).toEqual({ newSection: { value: 42 } });
|
||||
});
|
||||
|
||||
it('adds a new section alongside existing ones', () => {
|
||||
const existing = JSON.stringify({ existing: 'data' });
|
||||
const result = updateContentSection(existing, 'newSection', 'hello');
|
||||
expect(JSON.parse(result)).toEqual({ existing: 'data', newSection: 'hello' });
|
||||
});
|
||||
|
||||
it('overwrites an existing section', () => {
|
||||
const existing = JSON.stringify({ section: 'old', other: 'keep' });
|
||||
const result = updateContentSection(existing, 'section', 'new');
|
||||
expect(JSON.parse(result)).toEqual({ section: 'new', other: 'keep' });
|
||||
});
|
||||
|
||||
it('preserves all sibling keys', () => {
|
||||
const existing = JSON.stringify({ a: 1, b: 2, c: 3, d: 4 });
|
||||
const result = updateContentSection(existing, 'b', 'updated');
|
||||
expect(JSON.parse(result)).toEqual({ a: 1, b: 'updated', c: 3, d: 4 });
|
||||
});
|
||||
|
||||
it('handles invalid JSON input gracefully', () => {
|
||||
const result = updateContentSection('bad json', 'section', 'value');
|
||||
expect(JSON.parse(result)).toEqual({ section: 'value' });
|
||||
});
|
||||
|
||||
it('can set a section to null', () => {
|
||||
const existing = JSON.stringify({ section: 'data' });
|
||||
const result = updateContentSection(existing, 'section', null);
|
||||
expect(JSON.parse(result)).toEqual({ section: null });
|
||||
});
|
||||
|
||||
it('can set a section to an array', () => {
|
||||
const existing = JSON.stringify({ other: 'data' });
|
||||
const result = updateContentSection(existing, 'items', [1, 2, 3]);
|
||||
expect(JSON.parse(result)).toEqual({ other: 'data', items: [1, 2, 3] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// src/blobbi/core/lib/content-json.ts
|
||||
|
||||
/**
|
||||
* Low-level JSON parsing utilities for Kind 11125 content.
|
||||
*
|
||||
* This module provides the shared parsing foundation that both
|
||||
* `blobbonaut-content.ts` and `progression.ts` build on.
|
||||
*
|
||||
* It is intentionally dependency-free (no imports from other blobbi modules)
|
||||
* to prevent circular imports. Higher-level modules import from here;
|
||||
* this module never imports from them.
|
||||
*/
|
||||
|
||||
// ─── Content Parsing Result ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of parsing kind 11125 content JSON.
|
||||
*
|
||||
* Includes a `parseOk` flag so callers can distinguish between:
|
||||
* - Empty/blank content (parseOk: true, data is {})
|
||||
* - Valid JSON (parseOk: true, data is the parsed object)
|
||||
* - Invalid JSON / non-object (parseOk: false, data is {})
|
||||
*
|
||||
* When `parseOk` is false, the content was corrupt. The data field is empty
|
||||
* so callers can still merge their update, but they should be aware that
|
||||
* any data from the corrupt content is unrecoverable.
|
||||
*/
|
||||
export interface ParsedContentResult {
|
||||
/** Whether the content was successfully parsed (or was empty/blank). */
|
||||
parseOk: boolean;
|
||||
/** The parsed data. Empty object when content is blank or unparseable. */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Safe Content Parsing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Safely parse kind 11125 content JSON into a plain object.
|
||||
*
|
||||
* Returns `{ parseOk, data }`:
|
||||
* - Empty/blank content → `{ parseOk: true, data: {} }`
|
||||
* - Valid JSON object → `{ parseOk: true, data: <parsed> }`
|
||||
* - Invalid JSON → `{ parseOk: false, data: {} }` + DEV warning
|
||||
* - Non-object JSON → `{ parseOk: false, data: {} }` + DEV warning
|
||||
*
|
||||
* All keys — known and unknown — are preserved in the returned data.
|
||||
*
|
||||
* This function never throws. It is the single entry point for all content
|
||||
* parsing in the kind 11125 system. Both `parseProfileContent` (typed
|
||||
* validation) and the section-update helpers use this under the hood.
|
||||
*
|
||||
* ── Invalid JSON behavior ─────────────────────────────────────────────────
|
||||
*
|
||||
* When content is invalid JSON:
|
||||
* - In development: a warning is logged with the first 200 chars of the
|
||||
* content and the parse error, so developers notice the issue.
|
||||
* - In production: fails silently (no console noise for end users).
|
||||
* - In both environments: returns `{ parseOk: false, data: {} }`.
|
||||
*
|
||||
* The caller can check `parseOk` to decide whether to proceed. All current
|
||||
* callers proceed regardless (merge their update into a fresh object) because
|
||||
* blocking all writes on corrupt content would leave the user stuck with no
|
||||
* recovery path. The corrupt data is lost, but the system stays functional.
|
||||
*/
|
||||
export function safeParseContent(content: string): ParsedContentResult {
|
||||
if (!content || content.trim() === '') {
|
||||
return { parseOk: true, data: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(content);
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[content-json] Content JSON parsed but is not a plain object. ' +
|
||||
'Falling back to empty object. Type:',
|
||||
Array.isArray(raw) ? 'array' : typeof raw,
|
||||
);
|
||||
}
|
||||
return { parseOk: false, data: {} };
|
||||
}
|
||||
return { parseOk: true, data: raw as Record<string, unknown> };
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[content-json] Failed to parse content JSON. Falling back to empty object.',
|
||||
'Content (first 200 chars):',
|
||||
content.slice(0, 200),
|
||||
'Error:',
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
}
|
||||
return { parseOk: false, data: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Generic Section Update ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update a single top-level section in the kind 11125 content JSON.
|
||||
*
|
||||
* This is the low-level building block for all section-specific helpers.
|
||||
* It guarantees:
|
||||
* 1. The existing content is safely parsed (invalid JSON → {} + warning)
|
||||
* 2. Only the specified `key` is written/overwritten
|
||||
* 3. All sibling sections and unknown keys are preserved
|
||||
* 4. The result is serialized to a valid JSON string
|
||||
*
|
||||
* Prefer the typed helpers (`updateDailyMissionsContent`,
|
||||
* `updateProgressionContent`) over calling this directly. Use this only
|
||||
* for truly generic/dynamic section updates, or when building a new
|
||||
* section-specific helper.
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param key - The top-level key to update (e.g. 'dailyMissions')
|
||||
* @param value - The new value for that key
|
||||
* @returns The serialized content string with the section updated
|
||||
*/
|
||||
export function updateContentSection(
|
||||
existingContent: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): string {
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
const updated = {
|
||||
...data,
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
return JSON.stringify(updated);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
deriveGlobalLevel,
|
||||
parseProgression,
|
||||
mergeProgression,
|
||||
upsertLevelTag,
|
||||
updateProgressionContent,
|
||||
createDefaultProgression,
|
||||
DEFAULT_BLOBBI_GAME_PROGRESSION,
|
||||
DEFAULT_BLOBBI_UNLOCKS,
|
||||
type Progression,
|
||||
type GameProgressionMap,
|
||||
} from './progression';
|
||||
|
||||
// ─── deriveGlobalLevel ────────────────────────────────────────────────────────
|
||||
|
||||
describe('deriveGlobalLevel', () => {
|
||||
it('returns 0 for an empty games map', () => {
|
||||
expect(deriveGlobalLevel({})).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the level of a single game', () => {
|
||||
const games: GameProgressionMap = {
|
||||
blobbi: { level: 5, xp: 100, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
};
|
||||
expect(deriveGlobalLevel(games)).toBe(5);
|
||||
});
|
||||
|
||||
it('sums levels from multiple games', () => {
|
||||
const games: GameProgressionMap = {
|
||||
blobbi: { level: 3, xp: 50, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
farm: { level: 7, xp: 200 },
|
||||
racing: { level: 2, xp: 10 },
|
||||
};
|
||||
expect(deriveGlobalLevel(games)).toBe(12);
|
||||
});
|
||||
|
||||
it('skips undefined or zero-level entries', () => {
|
||||
const games: GameProgressionMap = {
|
||||
blobbi: { level: 4, xp: 0, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
farm: undefined,
|
||||
racing: { level: 0, xp: 0 },
|
||||
};
|
||||
expect(deriveGlobalLevel(games)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseProgression ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseProgression', () => {
|
||||
it('returns undefined for non-objects', () => {
|
||||
expect(parseProgression(null)).toBeUndefined();
|
||||
expect(parseProgression(42)).toBeUndefined();
|
||||
expect(parseProgression('string')).toBeUndefined();
|
||||
expect(parseProgression([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when games is missing', () => {
|
||||
expect(parseProgression({ global: { level: 1, xp: 0 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when games is not an object', () => {
|
||||
expect(parseProgression({ global: { level: 1, xp: 0 }, games: 'bad' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses a valid Blobbi progression', () => {
|
||||
const raw = {
|
||||
global: { level: 99, xp: 500 }, // level should be re-derived, not trusted
|
||||
games: {
|
||||
blobbi: {
|
||||
level: 3,
|
||||
xp: 150,
|
||||
unlocks: { maxBlobbis: 2, realInventoryEnabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.global.level).toBe(3); // re-derived, not 99
|
||||
expect(result!.global.xp).toBe(500); // preserved as-is
|
||||
expect(result!.games.blobbi).toEqual({
|
||||
level: 3,
|
||||
xp: 150,
|
||||
unlocks: { maxBlobbis: 2, realInventoryEnabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults Blobbi unlocks for malformed unlock data', () => {
|
||||
const raw = {
|
||||
global: { level: 1, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 1, xp: 0, unlocks: 'not-an-object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result!.games.blobbi!.unlocks).toEqual(DEFAULT_BLOBBI_UNLOCKS);
|
||||
});
|
||||
|
||||
it('preserves unknown game entries', () => {
|
||||
const raw = {
|
||||
global: { level: 0, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 2, xp: 50, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
racing: { level: 5, xp: 300, unlocks: { turboEnabled: true } },
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result!.games.racing).toEqual({
|
||||
level: 5,
|
||||
xp: 300,
|
||||
unlocks: { turboEnabled: true },
|
||||
});
|
||||
expect(result!.global.level).toBe(7); // 2 + 5
|
||||
});
|
||||
|
||||
it('skips malformed game entries', () => {
|
||||
const raw = {
|
||||
global: { level: 0, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 1, xp: 0, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
bad: 'not-an-object',
|
||||
alsobad: null,
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(Object.keys(result!.games)).toEqual(['blobbi']);
|
||||
});
|
||||
|
||||
it('defaults missing numeric fields to 0', () => {
|
||||
const raw = {
|
||||
global: {},
|
||||
games: {
|
||||
blobbi: { unlocks: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const result = parseProgression(raw);
|
||||
expect(result!.games.blobbi!.level).toBe(0);
|
||||
expect(result!.games.blobbi!.xp).toBe(0);
|
||||
expect(result!.global.xp).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mergeProgression ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('mergeProgression', () => {
|
||||
const baseProgression: Progression = {
|
||||
global: { level: 3, xp: 100 },
|
||||
games: {
|
||||
blobbi: { level: 3, xp: 100, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
},
|
||||
};
|
||||
|
||||
it('initializes from undefined with Blobbi defaults when updating blobbi', () => {
|
||||
const result = mergeProgression(undefined, {
|
||||
games: { blobbi: { xp: 50 } },
|
||||
});
|
||||
|
||||
expect(result.games.blobbi).toEqual({
|
||||
level: 1, // default
|
||||
xp: 50, // from update
|
||||
unlocks: DEFAULT_BLOBBI_UNLOCKS,
|
||||
});
|
||||
expect(result.global.level).toBe(1);
|
||||
});
|
||||
|
||||
it('updates only the specified game field', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
games: { blobbi: { xp: 200 } },
|
||||
});
|
||||
|
||||
expect(result.games.blobbi!.level).toBe(3); // preserved
|
||||
expect(result.games.blobbi!.xp).toBe(200); // updated
|
||||
expect(result.games.blobbi!.unlocks).toEqual({ maxBlobbis: 1, realInventoryEnabled: false }); // preserved
|
||||
});
|
||||
|
||||
it('merges unlocks without dropping existing fields', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
games: { blobbi: { unlocks: { maxBlobbis: 3 } } },
|
||||
});
|
||||
|
||||
expect(result.games.blobbi!.unlocks).toEqual({
|
||||
maxBlobbis: 3, // updated
|
||||
realInventoryEnabled: false, // preserved
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves other games when updating one game', () => {
|
||||
const withMultiple: Progression = {
|
||||
global: { level: 8, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 3, xp: 100, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
farm: { level: 5, xp: 300 },
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeProgression(withMultiple, {
|
||||
games: { blobbi: { level: 4 } },
|
||||
});
|
||||
|
||||
expect(result.games.farm).toEqual({ level: 5, xp: 300 }); // untouched
|
||||
expect(result.games.blobbi!.level).toBe(4);
|
||||
expect(result.global.level).toBe(9); // 4 + 5
|
||||
});
|
||||
|
||||
it('always re-derives global level, ignoring caller-provided value', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
global: { level: 999 }, // should be ignored
|
||||
games: { blobbi: { level: 7 } },
|
||||
});
|
||||
|
||||
expect(result.global.level).toBe(7); // derived, not 999
|
||||
});
|
||||
|
||||
it('preserves global.xp from existing when not in update', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
games: { blobbi: { level: 4 } },
|
||||
});
|
||||
|
||||
expect(result.global.xp).toBe(100); // from base
|
||||
});
|
||||
|
||||
it('updates global.xp when provided', () => {
|
||||
const result = mergeProgression(baseProgression, {
|
||||
global: { xp: 500 },
|
||||
});
|
||||
|
||||
expect(result.global.xp).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── upsertLevelTag ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('upsertLevelTag', () => {
|
||||
it('appends level tag when none exists', () => {
|
||||
const tags = [['d', 'abc'], ['name', 'test']];
|
||||
const result = upsertLevelTag(tags, 5);
|
||||
|
||||
expect(result).toEqual([['d', 'abc'], ['name', 'test'], ['level', '5']]);
|
||||
});
|
||||
|
||||
it('updates existing level tag in place', () => {
|
||||
const tags = [['d', 'abc'], ['level', '3'], ['name', 'test']];
|
||||
const result = upsertLevelTag(tags, 7);
|
||||
|
||||
expect(result).toEqual([['d', 'abc'], ['level', '7'], ['name', 'test']]);
|
||||
});
|
||||
|
||||
it('does not mutate the original array', () => {
|
||||
const tags = [['d', 'abc'], ['level', '3']];
|
||||
const original = JSON.parse(JSON.stringify(tags));
|
||||
upsertLevelTag(tags, 10);
|
||||
|
||||
expect(tags).toEqual(original);
|
||||
});
|
||||
|
||||
it('handles level 0', () => {
|
||||
const tags = [['d', 'abc']];
|
||||
const result = upsertLevelTag(tags, 0);
|
||||
|
||||
expect(result).toEqual([['d', 'abc'], ['level', '0']]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateProgressionContent ─────────────────────────────────────────────────
|
||||
|
||||
describe('updateProgressionContent', () => {
|
||||
it('initializes progression in empty content', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('', {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression).toBeDefined();
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves existing dailyMissions when updating progression', () => {
|
||||
const existing = JSON.stringify({
|
||||
dailyMissions: { date: '2026-04-06', missions: [], bonusClaimed: false, rerollsRemaining: 3, totalXpEarned: 50, lastUpdatedAt: 1000 },
|
||||
});
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 2 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.dailyMissions).toBeDefined();
|
||||
expect(parsed.dailyMissions.date).toBe('2026-04-06');
|
||||
expect(parsed.dailyMissions.totalXpEarned).toBe(50);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves unknown top-level keys', () => {
|
||||
const existing = JSON.stringify({
|
||||
dailyMissions: { date: '2026-04-06', missions: [], bonusClaimed: false, rerollsRemaining: 3, totalXpEarned: 0, lastUpdatedAt: 0 },
|
||||
futureFeature: { some: 'data' },
|
||||
});
|
||||
|
||||
const { content } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { xp: 100 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.futureFeature).toEqual({ some: 'data' });
|
||||
});
|
||||
|
||||
it('handles corrupt content gracefully', () => {
|
||||
const { content, globalLevel } = updateProgressionContent('not valid json!!!', {
|
||||
games: { blobbi: { level: 1, xp: 0 } },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.games.blobbi.level).toBe(1);
|
||||
expect(globalLevel).toBe(1);
|
||||
// dailyMissions should NOT be present (corrupt content had none)
|
||||
expect(parsed.dailyMissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-derives global level correctly in content', () => {
|
||||
const existing = JSON.stringify({
|
||||
progression: {
|
||||
global: { level: 5, xp: 0 },
|
||||
games: {
|
||||
blobbi: { level: 3, xp: 100, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } },
|
||||
farm: { level: 2, xp: 50 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { content, globalLevel } = updateProgressionContent(existing, {
|
||||
games: { blobbi: { level: 4 } },
|
||||
});
|
||||
|
||||
expect(globalLevel).toBe(6); // 4 + 2
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.progression.global.level).toBe(6);
|
||||
expect(parsed.progression.games.farm.level).toBe(2); // untouched
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createDefaultProgression ─────────────────────────────────────────────────
|
||||
|
||||
describe('createDefaultProgression', () => {
|
||||
it('returns a valid default progression', () => {
|
||||
const def = createDefaultProgression();
|
||||
|
||||
expect(def.global.level).toBe(1);
|
||||
expect(def.global.xp).toBe(0);
|
||||
expect(def.games.blobbi).toBeDefined();
|
||||
expect(def.games.blobbi!.level).toBe(1);
|
||||
expect(def.games.blobbi!.xp).toBe(0);
|
||||
expect(def.games.blobbi!.unlocks).toEqual(DEFAULT_BLOBBI_UNLOCKS);
|
||||
});
|
||||
|
||||
it('returns independent copies (no shared references)', () => {
|
||||
const a = createDefaultProgression();
|
||||
const b = createDefaultProgression();
|
||||
|
||||
a.games.blobbi!.level = 99;
|
||||
expect(b.games.blobbi!.level).toBe(1);
|
||||
|
||||
a.games.blobbi!.unlocks.maxBlobbis = 99;
|
||||
expect(b.games.blobbi!.unlocks.maxBlobbis).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DEFAULT_BLOBBI_GAME_PROGRESSION ──────────────────────────────────────────
|
||||
|
||||
describe('DEFAULT_BLOBBI_GAME_PROGRESSION', () => {
|
||||
it('has the expected shape', () => {
|
||||
expect(DEFAULT_BLOBBI_GAME_PROGRESSION).toEqual({
|
||||
level: 1,
|
||||
xp: 0,
|
||||
unlocks: { maxBlobbis: 1, realInventoryEnabled: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
// src/blobbi/core/lib/progression.ts
|
||||
|
||||
/**
|
||||
* Blobbonaut Progression System — Types, defaults, derivation, and merge helpers.
|
||||
*
|
||||
* This module defines the progression structure that lives inside the kind 11125
|
||||
* event content JSON alongside `dailyMissions` and any future top-level keys.
|
||||
*
|
||||
* ── Design Rationale ──────────────────────────────────────────────────────────
|
||||
*
|
||||
* Why `progression.games` is the source of truth:
|
||||
* Each game (blobbi, farm, racing, …) independently tracks its own level and
|
||||
* XP. This makes it straightforward to add new games without affecting
|
||||
* existing ones. The per-game data is authoritative; the global summary is
|
||||
* always derived from it.
|
||||
*
|
||||
* Why `progression.global.level` is derived, not primary:
|
||||
* A single authoritative global level would need to be kept in sync with every
|
||||
* game mutation — an error-prone process that silently corrupts data if any
|
||||
* write path forgets to update both. Instead, we derive the global level as
|
||||
* the sum of all game levels immediately before serialization, making it
|
||||
* impossible to drift out of sync.
|
||||
*
|
||||
* Why `progression.global.xp` exists but has no gameplay rules yet:
|
||||
* We reserve the field in the schema so future phases can introduce global XP
|
||||
* accumulation without a schema migration. For now it is always written as-is
|
||||
* and never used for derivation or gating.
|
||||
*
|
||||
* Why the merge logic must be conservative:
|
||||
* Multiple write paths update kind 11125 content (daily missions, shop
|
||||
* purchases via tags, profile normalization, etc.). Each write path must:
|
||||
* 1. Parse existing content (never assume shape)
|
||||
* 2. Touch only its own section
|
||||
* 3. Preserve every unknown key at every level
|
||||
* A shallow spread at the top level is not enough — the `progression` object
|
||||
* itself contains nested structures (`games`, each game's `unlocks`) that must
|
||||
* be merged recursively without dropping siblings.
|
||||
*
|
||||
* ── Standard Write Path ───────────────────────────────────────────────────────
|
||||
*
|
||||
* Every kind 11125 content write that touches `progression` should flow
|
||||
* through `updateProgressionContent()`. This guarantees:
|
||||
* - Unknown top-level keys (dailyMissions, future sections) are never dropped
|
||||
* - `global.level` is always consistent with game data
|
||||
* - The `["level", "<n>"]` tag can be updated from the returned `globalLevel`
|
||||
* - Only the `progression` section is modified; everything else is preserved
|
||||
*
|
||||
* The `["level", "<n>"]` tag is a queryable mirror only — it exists so relays
|
||||
* can filter profiles by level without parsing content JSON. It must never be
|
||||
* treated as a source of truth.
|
||||
*/
|
||||
|
||||
import { safeParseContent } from './content-json';
|
||||
|
||||
// ─── Game Identifiers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Known game identifiers within the progression system.
|
||||
* New games are added here as string literals for type safety.
|
||||
* The structure also accepts unknown game keys for forward compatibility.
|
||||
*/
|
||||
export type KnownGameId = 'blobbi';
|
||||
|
||||
// ─── Unlock Shapes ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unlock flags for the Blobbi game specifically.
|
||||
* Each flag controls a capability that becomes available at certain levels.
|
||||
*/
|
||||
export interface BlobbiUnlocks {
|
||||
/** Maximum number of Blobbis the player may own simultaneously. */
|
||||
maxBlobbis: number;
|
||||
/** Whether the real (non-preview) inventory system is enabled. */
|
||||
realInventoryEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default unlocks for a brand-new Blobbi game progression.
|
||||
* Level 1 players start with 1 Blobbi slot and no real inventory.
|
||||
*/
|
||||
export const DEFAULT_BLOBBI_UNLOCKS: Readonly<BlobbiUnlocks> = {
|
||||
maxBlobbis: 1,
|
||||
realInventoryEnabled: false,
|
||||
} as const;
|
||||
|
||||
// ─── Per-Game Progression ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base shape shared by every game's progression entry.
|
||||
* Individual games extend this with their own `unlocks` type.
|
||||
*/
|
||||
export interface BaseGameProgression {
|
||||
/** The game's current level (starts at 1 for initialized games). */
|
||||
level: number;
|
||||
/** The game's current XP towards the next level. */
|
||||
xp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blobbi game progression entry.
|
||||
*/
|
||||
export interface BlobbiGameProgression extends BaseGameProgression {
|
||||
unlocks: BlobbiUnlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `progression.games` map.
|
||||
*
|
||||
* Known games get explicit types for editor support and validation.
|
||||
* Unknown game keys are accepted as `BaseGameProgression & { unlocks?: unknown }`
|
||||
* for forward compatibility — a newer client version may write game entries we
|
||||
* don't recognize yet.
|
||||
*/
|
||||
export interface GameProgressionMap {
|
||||
blobbi?: BlobbiGameProgression;
|
||||
/** Forward-compatible catch-all for future games. */
|
||||
[gameId: string]: (BaseGameProgression & { unlocks?: unknown }) | undefined;
|
||||
}
|
||||
|
||||
// ─── Global Progression ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The derived global summary.
|
||||
*
|
||||
* `level` is always the sum of all `games.*.level`. It is recalculated
|
||||
* before every write and should never be manually set by callers.
|
||||
*
|
||||
* `xp` is reserved for future use. It is preserved as-is during
|
||||
* read-modify-write but no gameplay rules depend on it yet.
|
||||
*/
|
||||
export interface GlobalProgression {
|
||||
level: number;
|
||||
xp: number;
|
||||
}
|
||||
|
||||
// ─── Top-Level Progression ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The full `progression` section of the kind 11125 content JSON.
|
||||
*/
|
||||
export interface Progression {
|
||||
global: GlobalProgression;
|
||||
games: GameProgressionMap;
|
||||
}
|
||||
|
||||
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default progression for a brand-new Blobbi game entry.
|
||||
* This is the starting state when a player first enters the Blobbi game.
|
||||
*/
|
||||
export const DEFAULT_BLOBBI_GAME_PROGRESSION: Readonly<BlobbiGameProgression> = {
|
||||
level: 1,
|
||||
xp: 0,
|
||||
unlocks: { ...DEFAULT_BLOBBI_UNLOCKS },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Build a fresh progression structure with only the Blobbi game initialized.
|
||||
* The global level is derived (sum of game levels = 1).
|
||||
*/
|
||||
export function createDefaultProgression(): Progression {
|
||||
return {
|
||||
global: { level: 1, xp: 0 },
|
||||
games: {
|
||||
blobbi: { ...DEFAULT_BLOBBI_GAME_PROGRESSION, unlocks: { ...DEFAULT_BLOBBI_UNLOCKS } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Derivation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive the global level from the sum of all per-game levels.
|
||||
*
|
||||
* This is the **only** correct way to determine the global level.
|
||||
* Never read `progression.global.level` as authoritative — always re-derive
|
||||
* before comparing or persisting.
|
||||
*
|
||||
* Games that are `undefined` or missing a numeric `level` are skipped.
|
||||
*/
|
||||
export function deriveGlobalLevel(games: GameProgressionMap): number {
|
||||
let total = 0;
|
||||
for (const gameId of Object.keys(games)) {
|
||||
const game = games[gameId];
|
||||
if (game && typeof game.level === 'number' && game.level > 0) {
|
||||
total += game.level;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate and normalize a raw `progression` value from parsed JSON.
|
||||
*
|
||||
* - Returns `undefined` if the value is not a usable object (caller decides
|
||||
* whether to initialize defaults or leave absent).
|
||||
* - Preserves unknown game keys and unknown fields within game entries.
|
||||
* - Validates the Blobbi game entry with type-specific checks.
|
||||
* - Re-derives `global.level` from game data to ensure consistency.
|
||||
*
|
||||
* This function never throws. Malformed sub-trees are silently dropped or
|
||||
* defaulted so that a corrupt `progression` field cannot crash the app.
|
||||
*/
|
||||
export function parseProgression(raw: unknown): Progression | undefined {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
// ── Parse games ──
|
||||
|
||||
const rawGames = obj.games;
|
||||
if (typeof rawGames !== 'object' || rawGames === null || Array.isArray(rawGames)) {
|
||||
// No usable games map — cannot construct a valid progression.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const gamesObj = rawGames as Record<string, unknown>;
|
||||
const games: GameProgressionMap = {};
|
||||
|
||||
for (const gameId of Object.keys(gamesObj)) {
|
||||
const rawGame = gamesObj[gameId];
|
||||
if (typeof rawGame !== 'object' || rawGame === null || Array.isArray(rawGame)) {
|
||||
continue; // Skip malformed game entries
|
||||
}
|
||||
|
||||
const gameEntry = rawGame as Record<string, unknown>;
|
||||
const level = typeof gameEntry.level === 'number' ? gameEntry.level : 0;
|
||||
const xp = typeof gameEntry.xp === 'number' ? gameEntry.xp : 0;
|
||||
|
||||
if (gameId === 'blobbi') {
|
||||
// Type-specific parsing for Blobbi
|
||||
games.blobbi = {
|
||||
level,
|
||||
xp,
|
||||
unlocks: parseBlobbiUnlocks(gameEntry.unlocks),
|
||||
};
|
||||
} else {
|
||||
// Forward-compatible: preserve unknown games with their base fields + unlocks
|
||||
const entry: BaseGameProgression & { unlocks?: unknown } = { level, xp };
|
||||
if (gameEntry.unlocks !== undefined) {
|
||||
entry.unlocks = gameEntry.unlocks;
|
||||
}
|
||||
games[gameId] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse global (re-derive level for consistency) ──
|
||||
|
||||
const rawGlobal = obj.global;
|
||||
const globalXp =
|
||||
typeof rawGlobal === 'object' && rawGlobal !== null && !Array.isArray(rawGlobal)
|
||||
? typeof (rawGlobal as Record<string, unknown>).xp === 'number'
|
||||
? (rawGlobal as Record<string, unknown>).xp as number
|
||||
: 0
|
||||
: 0;
|
||||
|
||||
return {
|
||||
global: {
|
||||
level: deriveGlobalLevel(games),
|
||||
xp: globalXp,
|
||||
},
|
||||
games,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate Blobbi-specific unlocks from raw JSON.
|
||||
* Falls back to defaults for any missing or malformed fields.
|
||||
*/
|
||||
function parseBlobbiUnlocks(raw: unknown): BlobbiUnlocks {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_BLOBBI_UNLOCKS };
|
||||
}
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
maxBlobbis:
|
||||
typeof obj.maxBlobbis === 'number' && obj.maxBlobbis >= 1
|
||||
? obj.maxBlobbis
|
||||
: DEFAULT_BLOBBI_UNLOCKS.maxBlobbis,
|
||||
realInventoryEnabled:
|
||||
typeof obj.realInventoryEnabled === 'boolean'
|
||||
? obj.realInventoryEnabled
|
||||
: DEFAULT_BLOBBI_UNLOCKS.realInventoryEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Merge Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deep-merge an update into an existing Progression structure.
|
||||
*
|
||||
* Merge rules (conservative, section-scoped):
|
||||
* 1. `games` entries are merged per-key: only the specified game is touched.
|
||||
* 2. Unmentioned games are preserved exactly as-is.
|
||||
* 3. Within a game entry, each field is individually merged — unknown fields
|
||||
* within the game entry are preserved.
|
||||
* 4. `unlocks` within a game entry are shallow-merged (known fields override,
|
||||
* unknown fields preserved).
|
||||
* 5. `global.level` is always re-derived after merging — callers never set it.
|
||||
* 6. `global.xp` is preserved from existing unless explicitly provided.
|
||||
*
|
||||
* @param existing - The current progression (may be `undefined` for first-time init)
|
||||
* @param update - A partial progression update. Only specified paths are written.
|
||||
* @returns - The merged Progression with re-derived global level.
|
||||
*/
|
||||
export function mergeProgression(
|
||||
existing: Progression | undefined,
|
||||
update: DeepPartialProgression,
|
||||
): Progression {
|
||||
// Start from existing or create a minimal scaffold
|
||||
const base: Progression = existing ?? { global: { level: 0, xp: 0 }, games: {} };
|
||||
|
||||
// ── Merge games ──
|
||||
|
||||
const mergedGames: GameProgressionMap = { ...base.games };
|
||||
|
||||
if (update.games) {
|
||||
for (const gameId of Object.keys(update.games)) {
|
||||
const existingGame = mergedGames[gameId];
|
||||
const updateGame = (update.games as Record<string, unknown>)[gameId];
|
||||
|
||||
if (typeof updateGame !== 'object' || updateGame === null) {
|
||||
continue; // Skip invalid updates
|
||||
}
|
||||
|
||||
const updateObj = updateGame as Record<string, unknown>;
|
||||
|
||||
if (gameId === 'blobbi') {
|
||||
// Type-safe merge for Blobbi
|
||||
const existingBlobbi = (existingGame as BlobbiGameProgression | undefined);
|
||||
mergedGames.blobbi = mergeBlobbiGame(existingBlobbi, updateObj);
|
||||
} else {
|
||||
// Generic merge for unknown games
|
||||
const existingEntry = existingGame as (BaseGameProgression & { unlocks?: unknown }) | undefined;
|
||||
mergedGames[gameId] = mergeGenericGame(existingEntry, updateObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Re-derive global ──
|
||||
//
|
||||
// `global.level` is ALWAYS the sum of game levels. This is non-negotiable —
|
||||
// even if the caller provides `update.global.level`, we ignore it.
|
||||
// `global.xp` is preserved from existing unless the update explicitly provides it.
|
||||
|
||||
const mergedGlobalXp =
|
||||
update.global && typeof update.global.xp === 'number'
|
||||
? update.global.xp
|
||||
: base.global.xp;
|
||||
|
||||
return {
|
||||
global: {
|
||||
level: deriveGlobalLevel(mergedGames),
|
||||
xp: mergedGlobalXp,
|
||||
},
|
||||
games: mergedGames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an update into an existing Blobbi game progression entry.
|
||||
* Preserves existing fields not mentioned in the update.
|
||||
*/
|
||||
function mergeBlobbiGame(
|
||||
existing: BlobbiGameProgression | undefined,
|
||||
update: Record<string, unknown>,
|
||||
): BlobbiGameProgression {
|
||||
const base = existing ?? {
|
||||
...DEFAULT_BLOBBI_GAME_PROGRESSION,
|
||||
unlocks: { ...DEFAULT_BLOBBI_UNLOCKS },
|
||||
};
|
||||
|
||||
const merged: BlobbiGameProgression = {
|
||||
level: typeof update.level === 'number' ? update.level : base.level,
|
||||
xp: typeof update.xp === 'number' ? update.xp : base.xp,
|
||||
unlocks: base.unlocks,
|
||||
};
|
||||
|
||||
// Merge unlocks if provided
|
||||
if (typeof update.unlocks === 'object' && update.unlocks !== null && !Array.isArray(update.unlocks)) {
|
||||
const unlockUpdate = update.unlocks as Record<string, unknown>;
|
||||
merged.unlocks = {
|
||||
maxBlobbis:
|
||||
typeof unlockUpdate.maxBlobbis === 'number'
|
||||
? unlockUpdate.maxBlobbis
|
||||
: base.unlocks.maxBlobbis,
|
||||
realInventoryEnabled:
|
||||
typeof unlockUpdate.realInventoryEnabled === 'boolean'
|
||||
? unlockUpdate.realInventoryEnabled
|
||||
: base.unlocks.realInventoryEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an update into an existing generic (unknown) game progression entry.
|
||||
*/
|
||||
function mergeGenericGame(
|
||||
existing: (BaseGameProgression & { unlocks?: unknown }) | undefined,
|
||||
update: Record<string, unknown>,
|
||||
): BaseGameProgression & { unlocks?: unknown } {
|
||||
const base = existing ?? { level: 0, xp: 0 };
|
||||
|
||||
const merged: BaseGameProgression & { unlocks?: unknown } = {
|
||||
level: typeof update.level === 'number' ? update.level : base.level,
|
||||
xp: typeof update.xp === 'number' ? update.xp : base.xp,
|
||||
};
|
||||
|
||||
// Preserve or update unlocks (opaque for unknown games)
|
||||
if (update.unlocks !== undefined) {
|
||||
merged.unlocks = update.unlocks;
|
||||
} else if (base.unlocks !== undefined) {
|
||||
merged.unlocks = base.unlocks;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ─── Tag Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upsert the `["level", "<value>"]` tag in a tag array.
|
||||
*
|
||||
* - If a `level` tag already exists, its value is updated in place.
|
||||
* - If no `level` tag exists, one is appended.
|
||||
* - All other tags are preserved exactly as-is (order, values, extra elements).
|
||||
*
|
||||
* This mirrors the derived `progression.global.level` into a queryable tag
|
||||
* so relays can filter profiles by level without parsing content JSON.
|
||||
*
|
||||
* @param tags - The current tag array (will not be mutated)
|
||||
* @param level - The derived global level to write
|
||||
* @returns - A new tag array with the level tag upserted
|
||||
*/
|
||||
export function upsertLevelTag(tags: string[][], level: number): string[][] {
|
||||
const levelStr = String(level);
|
||||
let found = false;
|
||||
|
||||
const result = tags.map((tag) => {
|
||||
if (tag[0] === 'level') {
|
||||
found = true;
|
||||
return ['level', levelStr];
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
result.push(['level', levelStr]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Centralized Content Update ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the `progression` section inside a kind 11125 content string.
|
||||
*
|
||||
* This is the **standard entry point** for any code path that needs to modify
|
||||
* Blobbi game progression (or any future game). It:
|
||||
*
|
||||
* 1. Parses the existing content safely (empty/invalid → empty object)
|
||||
* 2. Extracts the existing `progression` (may be `undefined`)
|
||||
* 3. Merges the update conservatively (only touches specified paths)
|
||||
* 4. Re-derives `global.level`
|
||||
* 5. Writes the merged `progression` back, preserving all sibling keys
|
||||
* (`dailyMissions`, any future keys, and any unknown keys)
|
||||
* 6. Returns both the serialized content string and the derived global level
|
||||
* so the caller can also upsert the `level` tag.
|
||||
*
|
||||
* ── Why this function should be the standard path ──
|
||||
*
|
||||
* Every future kind 11125 content write that touches `progression` should flow
|
||||
* through `updateProgressionContent` (or through a higher-level helper that
|
||||
* calls it). This guarantees:
|
||||
* - Unknown top-level keys are never dropped
|
||||
* - `dailyMissions` is never overwritten
|
||||
* - `global.level` is always consistent with game data
|
||||
* - The `level` tag can always be updated from the returned value
|
||||
*
|
||||
* @param existingContent - The current `event.content` string (may be empty)
|
||||
* @param progressionUpdate - A partial progression update
|
||||
* @returns `{ content, globalLevel }` — serialized content + derived level for the tag
|
||||
*/
|
||||
export function updateProgressionContent(
|
||||
existingContent: string,
|
||||
progressionUpdate: DeepPartialProgression,
|
||||
): { content: string; globalLevel: number } {
|
||||
// Step 1: Parse the full content safely. Unknown keys are preserved.
|
||||
const { data } = safeParseContent(existingContent);
|
||||
|
||||
// Step 2: Extract and merge progression
|
||||
const existingProgression = parseProgression(data.progression);
|
||||
const merged = mergeProgression(existingProgression, progressionUpdate);
|
||||
|
||||
// Step 3: Write merged progression back, preserving all other keys
|
||||
const updated = {
|
||||
...data,
|
||||
progression: merged,
|
||||
};
|
||||
|
||||
return {
|
||||
content: JSON.stringify(updated),
|
||||
globalLevel: merged.global.level,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Deep Partial Type ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A deep-partial type for progression updates.
|
||||
*
|
||||
* Callers provide only the paths they want to change. Unmentioned fields
|
||||
* at every nesting level are preserved from the existing state.
|
||||
*/
|
||||
export interface DeepPartialProgression {
|
||||
global?: Partial<GlobalProgression>;
|
||||
games?: {
|
||||
[gameId: string]: Partial<BaseGameProgression & { unlocks?: unknown }> | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: safeParseContent is imported from blobbonaut-content.ts (the shared
|
||||
// content parsing entry point for all kind 11125 content operations).
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* ProgressionDevPanel - DEV MODE ONLY
|
||||
*
|
||||
* Simple testing panel for manually triggering kind 11125 progression writes.
|
||||
* All actions flow through the proper centralized helpers:
|
||||
* - updateProgressionContent() for content JSON
|
||||
* - upsertLevelTag() for the queryable level tag
|
||||
* - fetchFreshEvent() + prev for safe read-modify-write
|
||||
*
|
||||
* This component is temporary and can be removed when progression is
|
||||
* integrated into real gameplay.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import { KIND_BLOBBONAUT_PROFILE } from '@/blobbi/core/lib/blobbi';
|
||||
import { parseProfileContent } from '@/blobbi/core/lib/blobbonaut-content';
|
||||
import { updateProgressionContent, upsertLevelTag } from '@/blobbi/core/lib/progression';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProgressionDevPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful write to update the cached profile event */
|
||||
onProfileUpdated?: (event: import('@nostrify/nostrify').NostrEvent) => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProgressionDevPanel({ isOpen, onClose, onProfileUpdated }: ProgressionDevPanelProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
|
||||
// Guard: only render in localhost dev mode
|
||||
if (!isLocalhostDev()) return null;
|
||||
|
||||
/**
|
||||
* Core write helper: fetch fresh event, apply progression update,
|
||||
* upsert level tag, publish. This is the pattern all future progression
|
||||
* writes should follow.
|
||||
*/
|
||||
async function applyProgressionUpdate(
|
||||
label: string,
|
||||
getUpdate: (currentContent: string) => Parameters<typeof updateProgressionContent>[1],
|
||||
) {
|
||||
if (!user?.pubkey) {
|
||||
toast({ title: 'Not logged in', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setLastResult(null);
|
||||
|
||||
try {
|
||||
// 1. Fetch fresh event (safe read-modify-write)
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
const existingContent = prev?.content ?? '';
|
||||
const existingTags = prev?.tags ?? [];
|
||||
|
||||
// 2. Apply progression update through centralized helper
|
||||
const progressionUpdate = getUpdate(existingContent);
|
||||
const { content: updatedContent, globalLevel } = updateProgressionContent(
|
||||
existingContent,
|
||||
progressionUpdate,
|
||||
);
|
||||
|
||||
// 3. Upsert level tag (queryable mirror)
|
||||
const updatedTags = upsertLevelTag(existingTags, globalLevel);
|
||||
|
||||
// 4. Publish
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: updatedContent,
|
||||
tags: updatedTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
onProfileUpdated?.(event);
|
||||
|
||||
// Show result
|
||||
const parsed = parseProfileContent(updatedContent);
|
||||
const blobbi = parsed.progression?.games?.blobbi;
|
||||
setLastResult(
|
||||
`${label}: level=${blobbi?.level ?? '?'}, xp=${blobbi?.xp ?? '?'}, global=${globalLevel}`,
|
||||
);
|
||||
|
||||
toast({ title: 'DEV: Progression updated', description: label });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setLastResult(`Error: ${msg}`);
|
||||
toast({ title: 'DEV: Write failed', description: msg, variant: 'destructive' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const addXp = (amount: number) =>
|
||||
applyProgressionUpdate(`+${amount} Blobbi XP`, (content) => {
|
||||
const parsed = parseProfileContent(content);
|
||||
const currentXp = parsed.progression?.games?.blobbi?.xp ?? 0;
|
||||
return { games: { blobbi: { xp: currentXp + amount } } };
|
||||
});
|
||||
|
||||
const addLevel = () =>
|
||||
applyProgressionUpdate('+1 Blobbi Level', (content) => {
|
||||
const parsed = parseProfileContent(content);
|
||||
const currentLevel = parsed.progression?.games?.blobbi?.level ?? 1;
|
||||
return { games: { blobbi: { level: currentLevel + 1 } } };
|
||||
});
|
||||
|
||||
const resetProgression = () =>
|
||||
applyProgressionUpdate('Reset Blobbi Progression', () => ({
|
||||
games: { blobbi: { level: 1, xp: 0, unlocks: { maxBlobbis: 1, realInventoryEnabled: false } } },
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Progression Dev Panel
|
||||
<Badge variant="outline" className="text-xs">DEV</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Test kind 11125 progression writes. All actions use the proper helpers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* XP buttons */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Blobbi XP</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => addXp(10)} disabled={busy}>
|
||||
+10 XP
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => addXp(50)} disabled={busy}>
|
||||
+50 XP
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => addXp(200)} disabled={busy}>
|
||||
+200 XP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Level buttons */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Blobbi Level</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={addLevel} disabled={busy}>
|
||||
+1 Level
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reset */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">Reset</p>
|
||||
<Button size="sm" variant="destructive" onClick={resetProgression} disabled={busy}>
|
||||
Reset Blobbi Progression
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Last result */}
|
||||
{lastResult && (
|
||||
<>
|
||||
<Separator />
|
||||
<p className="text-xs font-mono text-muted-foreground break-all">{lastResult}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -38,3 +38,6 @@ export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
|
||||
export { EmotionDevProvider } from './EmotionDevContext';
|
||||
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
|
||||
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
|
||||
|
||||
// Progression testing tools
|
||||
export { ProgressionDevPanel } from './ProgressionDevPanel';
|
||||
|
||||
@@ -300,7 +300,7 @@ export function BlobbiHatchingCeremony({
|
||||
|
||||
const updatedProfileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
content: currentProfile?.event.content ?? '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
@@ -499,7 +499,7 @@ export function BlobbiHatchingCeremony({
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
content: currentProfile.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
@@ -376,7 +376,7 @@ export function useBlobbiOnboarding({
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
content: profile.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
@@ -474,7 +474,7 @@ export function useBlobbiOnboarding({
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
content: profile.event.content,
|
||||
tags: updatedProfileTags,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/blobbi/rooms/components/BlobbiCareRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiCareRoom — Hygiene, care, and medicine room.
|
||||
*
|
||||
* Side actions depend on the currently focused carousel item:
|
||||
* - Hygiene focused: Towel (left) + Shower (right)
|
||||
* - Medicine focused: Treat (left) + spacer (right)
|
||||
*
|
||||
* Both left and right slots always render the same fixed width
|
||||
* so the bottom bar never shifts when switching item types.
|
||||
*/
|
||||
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { ShowerHead, Candy } from 'lucide-react';
|
||||
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
|
||||
|
||||
interface BlobbiCareRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) {
|
||||
const {
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
handleUseItemFromTab,
|
||||
isPublishing,
|
||||
actionInProgress,
|
||||
isActiveFloatingCompanion,
|
||||
} = ctx;
|
||||
|
||||
const hygieneItems = useMemo(() =>
|
||||
getLiveShopItems().filter(i => i.type === 'hygiene'),
|
||||
[]);
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
const towelItem = hygieneItems.find(i => i.id === 'hyg_towel');
|
||||
|
||||
// Carousel: hygiene (except towel) + medicine, each tagged with meta
|
||||
const carouselEntries = useMemo<CarouselEntry[]>(() => {
|
||||
const hygiene = getLiveShopItems()
|
||||
.filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'hygiene' }));
|
||||
const medicine = getLiveShopItems()
|
||||
.filter(i => i.type === 'medicine')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'medicine' }));
|
||||
return [...hygiene, ...medicine];
|
||||
}, []);
|
||||
|
||||
// Track the type of the currently focused carousel item
|
||||
const [focusedMeta, setFocusedMeta] = useState<string>(
|
||||
carouselEntries[0]?.meta ?? 'hygiene',
|
||||
);
|
||||
|
||||
const handleFocusChange = useCallback((entry: CarouselEntry) => {
|
||||
setFocusedMeta(entry.meta ?? 'hygiene');
|
||||
}, []);
|
||||
|
||||
const isHygieneFocused = focusedMeta === 'hygiene';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Left slot — always same width (button or spacer) */}
|
||||
{isHygieneFocused ? (
|
||||
towelItem ? (
|
||||
<RoomActionButton
|
||||
icon={<span className="text-2xl sm:text-3xl">{towelItem.icon}</span>}
|
||||
label="Towel"
|
||||
color="text-cyan-500"
|
||||
glowHex="#06b6d4"
|
||||
onClick={() => handleUseItemFromTab(towelItem.id)}
|
||||
disabled={isDisabled}
|
||||
loading={isUsingItem && usingItemId === towelItem.id}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<RoomActionButton
|
||||
icon={<Candy className="size-7 sm:size-9" />}
|
||||
label="Treat"
|
||||
color="text-pink-400"
|
||||
glowHex="#f472b6"
|
||||
onClick={() => {
|
||||
// Comfort treat — use a small food item as a reward after medicine
|
||||
const treat = getLiveShopItems().find(i => i.type === 'food');
|
||||
if (treat) handleUseItemFromTab(treat.id);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Center carousel */}
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<ItemCarousel
|
||||
items={carouselEntries}
|
||||
onUse={handleUseItemFromTab}
|
||||
activeItemId={isUsingItem ? usingItemId : null}
|
||||
disabled={isDisabled}
|
||||
onFocusChange={handleFocusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right slot — always same width (button or spacer) */}
|
||||
{isHygieneFocused ? (
|
||||
<RoomActionButton
|
||||
icon={<ShowerHead className="size-7 sm:size-9" />}
|
||||
label="Shower"
|
||||
color="text-blue-500"
|
||||
glowHex="#3b82f6"
|
||||
onClick={() => {
|
||||
const shampoo = hygieneItems.find(i => i.id === 'hyg_shampoo');
|
||||
if (shampoo) handleUseItemFromTab(shampoo.id);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// src/blobbi/rooms/components/BlobbiClosetRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiClosetRoom — Placeholder room for wardrobe / accessories.
|
||||
*
|
||||
* Uses the same bottom bar structure as other rooms for visual consistency,
|
||||
* with a centered placeholder message.
|
||||
*/
|
||||
|
||||
import { Shirt } from 'lucide-react';
|
||||
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
|
||||
interface BlobbiClosetRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* Bottom bar — same structure as other rooms */}
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-center gap-2 py-1">
|
||||
<Shirt className="size-5 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground/40 font-medium">
|
||||
Closet coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
// src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiHatcheryRoom — Incubation / evolution / progression room.
|
||||
*
|
||||
* Layout:
|
||||
* - BlobbiRoomHero (Blobbi visual + stats)
|
||||
* - Bottom center: main start/stop hatching or evolution button
|
||||
* - Bottom right: quests/tasks button
|
||||
* - Bottom left: Blobbis list/selector button
|
||||
*
|
||||
* Reuses existing hatch/evolve/missions logic from BlobbiPage.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Loader2, Sparkles, Egg, Target, Check, ListTodo,
|
||||
Wrench, Droplets, Heart, Zap, Moon, Camera, Music, Mic,
|
||||
Pill, Utensils, Plus, Footprints, ExternalLink, Theater, TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { isLocalhostDev } from '@/blobbi/dev';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
|
||||
// ─── Helper: companionNeedsCare (reused from BlobbiPage) ──────────────────────
|
||||
|
||||
const CARE_THRESHOLD = 40;
|
||||
|
||||
function companionNeedsCare(companion: { stats: { hunger?: number; happiness?: number; hygiene?: number; health?: number } }): boolean {
|
||||
const { stats } = companion;
|
||||
return (
|
||||
(stats.hunger !== undefined && stats.hunger < CARE_THRESHOLD) ||
|
||||
(stats.happiness !== undefined && stats.happiness < CARE_THRESHOLD) ||
|
||||
(stats.hygiene !== undefined && stats.hygiene < CARE_THRESHOLD) ||
|
||||
(stats.health !== undefined && stats.health < CARE_THRESHOLD)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiHatcheryRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) {
|
||||
const {
|
||||
companion,
|
||||
companions,
|
||||
selectedD,
|
||||
profile,
|
||||
isEgg,
|
||||
isBaby,
|
||||
isIncubating,
|
||||
isEvolvingState,
|
||||
canStartIncubation,
|
||||
canStartEvolution,
|
||||
isStartingIncubation,
|
||||
isStartingEvolution,
|
||||
isStoppingIncubation,
|
||||
isStoppingEvolution,
|
||||
isHatching,
|
||||
isEvolving,
|
||||
hatchTasks,
|
||||
evolveTasks,
|
||||
onStartIncubation,
|
||||
onStartEvolution,
|
||||
onStopIncubation,
|
||||
onStopEvolution,
|
||||
onEvolve,
|
||||
setShowPostModal,
|
||||
setShowHatchCeremony,
|
||||
isActiveFloatingCompanion,
|
||||
// Blobbi selector
|
||||
onSelectBlobbi,
|
||||
blobbiNaddr,
|
||||
// Adoption
|
||||
setShowAdoptionFlow,
|
||||
// Daily missions
|
||||
dailyMissions,
|
||||
onClaimReward,
|
||||
isClaimingReward,
|
||||
// DEV
|
||||
setShowDevEditor,
|
||||
setShowEmotionPanel,
|
||||
setShowProgressionPanel,
|
||||
} = ctx;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Side panels
|
||||
const [showQuestsPanel, setShowQuestsPanel] = useState(false);
|
||||
const [showBlobbisPanel, setShowBlobbisPanel] = useState(false);
|
||||
|
||||
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
|
||||
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
|
||||
|
||||
const tasks = isIncubating ? hatchTasks.tasks : evolveTasks.tasks;
|
||||
const allCompleted = isIncubating ? hatchTasks.allCompleted : evolveTasks.allCompleted;
|
||||
const isTasksLoading = isIncubating ? hatchTasks.isLoading : evolveTasks.isLoading;
|
||||
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalCount = tasks.length;
|
||||
|
||||
const { missions } = dailyMissions;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* ── Hero ── */}
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* ── Bottom Action Bar ── */}
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Left — Blobbis selector */}
|
||||
<RoomActionButton
|
||||
icon={<Egg className="size-7 sm:size-9" />}
|
||||
label="Blobbis"
|
||||
color="text-primary"
|
||||
glowHex="var(--primary)"
|
||||
onClick={() => setShowBlobbisPanel(true)}
|
||||
badge={companions.length > 1 ? (
|
||||
<span className="size-4 sm:size-5 rounded-full bg-primary text-[9px] sm:text-[10px] text-primary-foreground font-bold flex items-center justify-center">
|
||||
{companions.length}
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
{/* Center — Main hatch/evolve action */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-1.5">
|
||||
{/* Active process: Hatch/Evolve CTA or progress */}
|
||||
{hasActiveProcess && allCompleted && !isTasksLoading && (
|
||||
<button
|
||||
onClick={isIncubating ? () => setShowHatchCeremony(true) : onEvolve}
|
||||
disabled={isProcessBusy}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
isProcessBusy && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
background: isIncubating
|
||||
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
|
||||
}}
|
||||
>
|
||||
{(isHatching || isEvolving) ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<span className="text-lg">{isIncubating ? '\uD83D\uDC23' : '\u2728'}</span>
|
||||
)}
|
||||
<span>{(isHatching || isEvolving) ? (isIncubating ? 'Hatching...' : 'Evolving...') : (isIncubating ? 'Hatch!' : 'Evolve!')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasActiveProcess && !allCompleted && !isTasksLoading && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
<span className="font-medium">{isIncubating ? 'Hatching' : 'Evolving'}</span>
|
||||
<span className="text-xs tabular-nums">{completedCount}/{totalCount}</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-40 h-1.5 rounded-full bg-muted/30 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
|
||||
background: isIncubating
|
||||
? 'linear-gradient(90deg, #0ea5e9, #8b5cf6)'
|
||||
: 'linear-gradient(90deg, #8b5cf6, #ec4899)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveProcess && isTasksLoading && (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* No active process — show start button */}
|
||||
{!hasActiveProcess && (canStartIncubation || canStartEvolution) && (
|
||||
<button
|
||||
onClick={() => canStartIncubation ? onStartIncubation('start') : onStartEvolution()}
|
||||
disabled={isStartingIncubation || isStartingEvolution}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
(isStartingIncubation || isStartingEvolution) && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
background: canStartIncubation
|
||||
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
|
||||
}}
|
||||
>
|
||||
{(isStartingIncubation || isStartingEvolution) ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-5" />
|
||||
)}
|
||||
<span>{canStartIncubation ? 'Begin Hatching' : 'Begin Evolution'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasActiveProcess && !canStartIncubation && !canStartEvolution && (
|
||||
<p className="text-xs text-muted-foreground/50">No journey available</p>
|
||||
)}
|
||||
|
||||
{/* Stop process link */}
|
||||
{hasActiveProcess && !isTasksLoading && (
|
||||
<button
|
||||
onClick={isIncubating ? onStopIncubation : onStopEvolution}
|
||||
disabled={isProcessBusy}
|
||||
className="text-[11px] text-muted-foreground/40 hover:text-destructive/60 transition-colors"
|
||||
>
|
||||
{(isStoppingIncubation || isStoppingEvolution) ? 'Stopping...' : `Stop ${isIncubating ? 'incubation' : 'evolution'}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — Quests/Tasks */}
|
||||
<RoomActionButton
|
||||
icon={<ListTodo className="size-7 sm:size-9" />}
|
||||
label="Quests"
|
||||
color="text-amber-500"
|
||||
glowHex="#f59e0b"
|
||||
onClick={() => setShowQuestsPanel(true)}
|
||||
badge={hasActiveProcess && totalCount - completedCount > 0 ? (
|
||||
<span className="size-4 sm:size-5 rounded-full bg-amber-500 text-[9px] sm:text-[10px] text-white font-bold flex items-center justify-center">
|
||||
{totalCount - completedCount}
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Quests Sheet ── */}
|
||||
<Sheet open={showQuestsPanel} onOpenChange={setShowQuestsPanel}>
|
||||
<SheetContent side="right" className="w-80 sm:w-96 p-0">
|
||||
<SheetHeader className="px-4 pt-4 pb-3 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<Target className="size-4" />
|
||||
Quests
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-4rem)]">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Journey tasks */}
|
||||
{hasActiveProcess && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{isIncubating ? 'Hatching Journey' : 'Evolution Journey'}
|
||||
</h3>
|
||||
{isTasksLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!isTasksLoading && tasks.map(task => {
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
switch (task.action) {
|
||||
case 'navigate': navigate(task.actionTarget); setShowQuestsPanel(false); break;
|
||||
case 'external_link': openUrl(task.actionTarget); break;
|
||||
case 'open_modal': if (task.actionTarget === 'blobbi_post') { setShowPostModal(true); setShowQuestsPanel(false); } break;
|
||||
}
|
||||
};
|
||||
const isActionable = !task.completed && !!task.action && !!task.actionTarget;
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={isActionable ? handleAction : undefined}
|
||||
disabled={!isActionable}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all text-left',
|
||||
isActionable && 'hover:bg-accent/50 active:scale-[0.98] cursor-pointer',
|
||||
!isActionable && 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<QuestTaskIcon taskId={task.id} completed={task.completed} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm font-medium leading-tight', task.completed && 'text-muted-foreground line-through')}>{task.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-1">{task.description}</p>
|
||||
</div>
|
||||
{task.required > 1 && !task.completed && (
|
||||
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{task.current}/{task.required}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasActiveProcess && (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground">Start a journey to unlock tasks</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily Bounties */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Daily Bounties
|
||||
</h3>
|
||||
{dailyMissions.noMissionsAvailable && (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center">
|
||||
<Egg className="size-5 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground">Hatch your Blobbi to unlock bounties</p>
|
||||
</div>
|
||||
)}
|
||||
{!dailyMissions.noMissionsAvailable && missions.map(mission => {
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
return (
|
||||
<div
|
||||
key={mission.id}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all',
|
||||
canClaim && 'bg-amber-500/[0.06]',
|
||||
)}
|
||||
>
|
||||
<DailyMissionIcon action={mission.action} claimed={mission.claimed} canClaim={canClaim} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm font-medium leading-tight', mission.claimed && 'text-muted-foreground line-through')}>{mission.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{mission.description}</p>
|
||||
</div>
|
||||
{!mission.claimed && (
|
||||
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{mission.currentCount}/{mission.requiredCount}</span>
|
||||
)}
|
||||
{canClaim && (
|
||||
<button
|
||||
onClick={() => onClaimReward(mission.id)}
|
||||
disabled={isClaimingReward}
|
||||
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
|
||||
>
|
||||
Claim
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Bonus row */}
|
||||
{!dailyMissions.noMissionsAvailable && dailyMissions.bonusAvailable && !dailyMissions.bonusClaimed && (
|
||||
<div className="w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl bg-amber-500/[0.06]">
|
||||
<div className="size-8 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
|
||||
<Sparkles className="size-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">Daily Champion</p>
|
||||
<p className="text-[10px] text-muted-foreground">All missions complete!</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onClaimReward('bonus_daily_complete')}
|
||||
disabled={isClaimingReward}
|
||||
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
|
||||
>
|
||||
Claim
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ── Blobbis Sheet ── */}
|
||||
<Sheet open={showBlobbisPanel} onOpenChange={setShowBlobbisPanel}>
|
||||
<SheetContent side="left" className="w-80 sm:w-96 p-0">
|
||||
<SheetHeader className="px-4 pt-4 pb-3 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<Egg className="size-4" />
|
||||
Your Blobbis
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-4rem)]">
|
||||
<div className="p-4">
|
||||
{/* Blobbi grid */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 py-3">
|
||||
{companions.map((c) => {
|
||||
const isSelected = c.d === selectedD;
|
||||
const isCompanion = c.d === profile?.currentCompanion;
|
||||
return (
|
||||
<button
|
||||
key={c.d}
|
||||
onClick={() => { onSelectBlobbi(c.d); setShowBlobbisPanel(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 transition-all duration-200',
|
||||
'hover:-translate-y-1 hover:scale-105 active:scale-95',
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={cn(
|
||||
'rounded-full p-1 transition-all',
|
||||
isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
|
||||
)}>
|
||||
<BlobbiStageVisual companion={c} size="sm" />
|
||||
</div>
|
||||
{isCompanion && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5 size-5 rounded-full bg-background ring-2 ring-background flex items-center justify-center">
|
||||
<Footprints className="size-3 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
{companionNeedsCare(c) && !isCompanion && (
|
||||
<div className="absolute -top-0.5 -right-0.5 size-4 rounded-full bg-amber-500 flex items-center justify-center">
|
||||
<span className="text-[8px] text-white font-bold">!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{c.stage !== 'egg' && (
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium max-w-[4.5rem] truncate',
|
||||
isSelected ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}>
|
||||
{c.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Adopt + button */}
|
||||
<button
|
||||
onClick={() => { setShowBlobbisPanel(false); setShowAdoptionFlow(true); }}
|
||||
className="flex flex-col items-center gap-1 transition-all duration-200 hover:-translate-y-1 hover:scale-105 active:scale-95"
|
||||
>
|
||||
<div className="size-14 rounded-full flex items-center justify-center" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, currentColor 10%, transparent), color-mix(in srgb, currentColor 3%, transparent) 70%)',
|
||||
}}>
|
||||
<Plus className="size-6 text-muted-foreground/60" />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/60">Adopt</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick actions row */}
|
||||
<div className="flex items-center justify-center gap-6 pt-3 border-t mt-3">
|
||||
<Link
|
||||
to={`/${blobbiNaddr}`}
|
||||
onClick={() => setShowBlobbisPanel(false)}
|
||||
className="flex flex-col items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
<span className="text-[10px]">View</span>
|
||||
</Link>
|
||||
{/* DEV tools */}
|
||||
{isLocalhostDev() && (
|
||||
<>
|
||||
{companion.stage !== 'adult' && (
|
||||
<button
|
||||
onClick={() => { setShowBlobbisPanel(false); if (isEgg) { setShowHatchCeremony(true); } else { onEvolve(); } }}
|
||||
disabled={isHatching || isEvolving}
|
||||
className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Sparkles className="size-5" />
|
||||
<span className="text-[10px]">{companion.stage === 'egg' ? 'Hatch' : 'Evolve'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setShowBlobbisPanel(false); setShowDevEditor(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
|
||||
<Wrench className="size-5" />
|
||||
<span className="text-[10px]">Editor</span>
|
||||
</button>
|
||||
<button onClick={() => { setShowBlobbisPanel(false); setShowEmotionPanel(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
|
||||
<Theater className="size-5" />
|
||||
<span className="text-[10px]">Emote</span>
|
||||
</button>
|
||||
<button onClick={() => { setShowBlobbisPanel(false); setShowProgressionPanel(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
|
||||
<TrendingUp className="size-5" />
|
||||
<span className="text-[10px]">Level</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Quest task icon (reused from BlobbiPage) ─────────────────────────────────
|
||||
|
||||
function QuestTaskIcon({ taskId, completed }: { taskId: string; completed: boolean }) {
|
||||
const iconClass = 'size-4';
|
||||
const icon = (() => {
|
||||
switch (taskId) {
|
||||
case 'create_themes': return <Sparkles className={iconClass} />;
|
||||
case 'color_moments': return <Droplets className={iconClass} />;
|
||||
case 'create_posts': return <Target className={iconClass} />;
|
||||
case 'interactions': return <Heart className={iconClass} />;
|
||||
case 'edit_profile': return <Wrench className={iconClass} />;
|
||||
case 'maintain_stats': return <Zap className={iconClass} />;
|
||||
default: return <Target className={iconClass} />;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className={cn(
|
||||
'size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
completed ? 'bg-emerald-500/15 text-emerald-500' : 'bg-muted/60 text-muted-foreground',
|
||||
)}>
|
||||
{completed ? <Check className="size-4" /> : icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily mission icon (reused from BlobbiPage) ──────────────────────────────
|
||||
|
||||
function DailyMissionIcon({ action, claimed, canClaim }: { action: string; claimed: boolean; canClaim: boolean }) {
|
||||
const iconClass = 'size-4';
|
||||
const icon = (() => {
|
||||
switch (action) {
|
||||
case 'interact': return <Heart className={iconClass} />;
|
||||
case 'feed': return <Utensils className={iconClass} />;
|
||||
case 'clean': return <Droplets className={iconClass} />;
|
||||
case 'sleep': return <Moon className={iconClass} />;
|
||||
case 'take_photo': return <Camera className={iconClass} />;
|
||||
case 'sing': return <Mic className={iconClass} />;
|
||||
case 'play_music': return <Music className={iconClass} />;
|
||||
case 'medicine': return <Pill className={iconClass} />;
|
||||
default: return <Target className={iconClass} />;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className={cn(
|
||||
'size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
claimed ? 'bg-emerald-500/15 text-emerald-500' : canClaim ? 'bg-amber-500/15 text-amber-500' : 'bg-muted/60 text-muted-foreground',
|
||||
)}>
|
||||
{claimed ? <Check className="size-4" /> : icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// src/blobbi/rooms/components/BlobbiHomeRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiHomeRoom — The main living / play room.
|
||||
*
|
||||
* Layout:
|
||||
* - BlobbiRoomHero (stats crown, Blobbi visual, name)
|
||||
* - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right)
|
||||
* - Inline activity (music player, sing card) above the bottom bar
|
||||
*
|
||||
* Sleep/wake has been moved to BlobbiRestRoom.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Camera, Footprints, Music, Mic } from 'lucide-react';
|
||||
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
|
||||
|
||||
interface BlobbiHomeRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
|
||||
const {
|
||||
isActiveFloatingCompanion,
|
||||
setShowPhotoModal,
|
||||
isCurrentCompanion,
|
||||
canBeCompanion,
|
||||
isUpdatingCompanion,
|
||||
handleSetAsCompanion,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
handleUseItemFromTab,
|
||||
handleDirectAction,
|
||||
isDirectActionPending,
|
||||
inlineActivity,
|
||||
handleConfirmSing,
|
||||
handleCloseInlineActivity,
|
||||
handleMusicPlaybackStart,
|
||||
handleMusicPlaybackStop,
|
||||
handleSingRecordingStart,
|
||||
handleSingRecordingStop,
|
||||
handleChangeTrack,
|
||||
isPublishing,
|
||||
actionInProgress,
|
||||
} = ctx;
|
||||
|
||||
// Build carousel entries: toys + music + sing
|
||||
const carouselItems = useMemo<CarouselEntry[]>(() => {
|
||||
const toys = getLiveShopItems()
|
||||
.filter(i => i.type === 'toy')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name }));
|
||||
|
||||
const actions: CarouselEntry[] = [
|
||||
{
|
||||
id: '__action_music',
|
||||
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-pink-500/15 text-pink-500"><Music className="size-5 sm:size-6" /></div>,
|
||||
label: 'Music',
|
||||
},
|
||||
{
|
||||
id: '__action_sing',
|
||||
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-purple-500/15 text-purple-500"><Mic className="size-5 sm:size-6" /></div>,
|
||||
label: 'Sing',
|
||||
},
|
||||
];
|
||||
|
||||
return [...toys, ...actions];
|
||||
}, []);
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
|
||||
const handleCarouselUse = (id: string) => {
|
||||
if (id === '__action_music') {
|
||||
handleDirectAction('play_music');
|
||||
} else if (id === '__action_sing') {
|
||||
handleDirectAction('sing');
|
||||
} else {
|
||||
handleUseItemFromTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* ── Hero (Blobbi + stats) ── */}
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* ── Inline Activity Area (music/sing) ── */}
|
||||
{inlineActivity.type === 'music' && (
|
||||
<div className="px-4 sm:px-6 pb-2">
|
||||
<InlineMusicPlayer
|
||||
selection={inlineActivity.selection}
|
||||
onChangeTrack={handleChangeTrack}
|
||||
onClose={handleCloseInlineActivity}
|
||||
onPlaybackStart={handleMusicPlaybackStart}
|
||||
onPlaybackStop={handleMusicPlaybackStop}
|
||||
isPublished={inlineActivity.isPublished}
|
||||
isPublishing={isDirectActionPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{inlineActivity.type === 'sing' && (
|
||||
<div className="px-4 sm:px-6 pb-2">
|
||||
<InlineSingCard
|
||||
onConfirm={handleConfirmSing}
|
||||
onClose={handleCloseInlineActivity}
|
||||
onRecordingStart={handleSingRecordingStart}
|
||||
onRecordingStop={handleSingRecordingStop}
|
||||
isPublishing={isDirectActionPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Unified Bottom Bar: Photo | Carousel | Companion ── */}
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Photo */}
|
||||
<RoomActionButton
|
||||
icon={<Camera className="size-7 sm:size-9" />}
|
||||
label="Photo"
|
||||
color="text-pink-500"
|
||||
glowHex="#ec4899"
|
||||
onClick={() => setShowPhotoModal(true)}
|
||||
/>
|
||||
|
||||
{/* Center carousel */}
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<ItemCarousel
|
||||
items={carouselItems}
|
||||
onUse={handleCarouselUse}
|
||||
activeItemId={isUsingItem ? usingItemId : null}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Companion toggle */}
|
||||
{canBeCompanion ? (
|
||||
<RoomActionButton
|
||||
icon={<Footprints className="size-7 sm:size-9" />}
|
||||
label={isCurrentCompanion ? 'With you' : 'Take along'}
|
||||
color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'}
|
||||
glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'}
|
||||
onClick={handleSetAsCompanion}
|
||||
disabled={isUpdatingCompanion}
|
||||
loading={isUpdatingCompanion}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// src/blobbi/rooms/components/BlobbiKitchenRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiKitchenRoom — The feeding room.
|
||||
*
|
||||
* Bottom bar: Shovel (left, when poop exists) | food carousel (center) | Fridge (right)
|
||||
* Poop appears at pre-computed safe positions in the lower corners.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Refrigerator, Shovel } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { BlobbiActionInventoryModal } from '@/blobbi/actions/components/BlobbiActionInventoryModal';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { getPoopsInRoom, hasAnyPoop } from '../lib/poop-system';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
|
||||
|
||||
interface BlobbiKitchenRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiKitchenRoom({ ctx, poopState }: BlobbiKitchenRoomProps) {
|
||||
const {
|
||||
companion,
|
||||
profile,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
handleUseItemFromTab,
|
||||
isPublishing,
|
||||
actionInProgress,
|
||||
isActiveFloatingCompanion,
|
||||
} = ctx;
|
||||
|
||||
const [showFridge, setShowFridge] = useState(false);
|
||||
|
||||
const foodEntries = useMemo<CarouselEntry[]>(() =>
|
||||
getLiveShopItems()
|
||||
.filter(i => i.type === 'food')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name })),
|
||||
[]);
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
|
||||
const handleFridgeUseItem = (itemId: string) => {
|
||||
if (isUsingItem) return;
|
||||
ctx.onUseItem(itemId, 'feed').finally(() => {
|
||||
setShowFridge(false);
|
||||
});
|
||||
};
|
||||
|
||||
const kitchenPoops = getPoopsInRoom(poopState.poops, 'kitchen');
|
||||
const anyPoopAnywhere = hasAnyPoop(poopState.poops);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* ── Hero + Poop layer ── */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* Poop at pre-computed safe positions */}
|
||||
{kitchenPoops.map((poop) => (
|
||||
<button
|
||||
key={poop.id}
|
||||
onClick={() => poopState.shovelMode && poopState.onRemovePoop(poop.id)}
|
||||
className={cn(
|
||||
'absolute z-10 transition-all duration-300',
|
||||
poopState.shovelMode
|
||||
? 'cursor-pointer hover:scale-150 active:scale-75'
|
||||
: 'pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
bottom: `${poop.position.bottom}%`,
|
||||
left: `${poop.position.left}%`,
|
||||
}}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-2xl sm:text-3xl block',
|
||||
poopState.shovelMode && 'drop-shadow-lg',
|
||||
)}>
|
||||
{'💩'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Bottom bar ── */}
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Left — Shovel (when poop exists) or spacer */}
|
||||
{anyPoopAnywhere ? (
|
||||
<RoomActionButton
|
||||
icon={<Shovel className="size-7 sm:size-9" />}
|
||||
label={poopState.shovelMode ? 'Done' : 'Shovel'}
|
||||
color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'}
|
||||
glowHex={poopState.shovelMode ? '#d97706' : '#78716c'}
|
||||
onClick={() => poopState.setShovelMode(prev => !prev)}
|
||||
className={poopState.shovelMode ? 'ring-2 ring-amber-500/40 ring-offset-2 ring-offset-background rounded-full' : ''}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Center: food carousel */}
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<ItemCarousel
|
||||
items={foodEntries}
|
||||
onUse={handleUseItemFromTab}
|
||||
activeItemId={isUsingItem ? usingItemId : null}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right — Fridge */}
|
||||
<RoomActionButton
|
||||
icon={<Refrigerator className="size-7 sm:size-9" />}
|
||||
label="Fridge"
|
||||
color="text-orange-500"
|
||||
glowHex="#f97316"
|
||||
onClick={() => setShowFridge(true)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFridge && (
|
||||
<BlobbiActionInventoryModal
|
||||
open={showFridge}
|
||||
onOpenChange={setShowFridge}
|
||||
action="feed"
|
||||
companion={companion}
|
||||
profile={profile}
|
||||
onUseItem={handleFridgeUseItem}
|
||||
onOpenShop={() => setShowFridge(false)}
|
||||
isUsingItem={isUsingItem}
|
||||
usingItemId={usingItemId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// src/blobbi/rooms/components/BlobbiRestRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiRestRoom — The bedroom / rest room.
|
||||
*
|
||||
* Bottom bar: Sleep/Wake button centered.
|
||||
*/
|
||||
|
||||
import { Moon, Sun, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
|
||||
interface BlobbiRestRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) {
|
||||
const {
|
||||
isEgg,
|
||||
isSleeping,
|
||||
onRest,
|
||||
actionInProgress,
|
||||
isPublishing,
|
||||
isUsingItem,
|
||||
isActiveFloatingCompanion,
|
||||
} = ctx;
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-center">
|
||||
{!isEgg && (
|
||||
<RoomActionButton
|
||||
icon={
|
||||
actionInProgress === 'rest'
|
||||
? <Loader2 className="size-7 sm:size-9 animate-spin" />
|
||||
: isSleeping
|
||||
? <Sun className="size-7 sm:size-9" />
|
||||
: <Moon className="size-7 sm:size-9" />
|
||||
}
|
||||
label={isSleeping ? 'Wake up' : 'Sleep'}
|
||||
color={isSleeping ? 'text-amber-500' : 'text-violet-500'}
|
||||
glowHex={isSleeping ? '#f59e0b' : '#8b5cf6'}
|
||||
onClick={onRest}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// src/blobbi/rooms/components/BlobbiRoomHero.tsx
|
||||
|
||||
/**
|
||||
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
|
||||
*
|
||||
* This component does NOT clip or constrain the visual — it simply fills
|
||||
* available flex space and centers the Blobbi + stats within it.
|
||||
* The room owns the full-height surface; this just provides content.
|
||||
*
|
||||
* Top padding accounts for the floating room header overlay (~40px).
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
|
||||
Footprints, Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiRoomContext } from '../lib/room-types';
|
||||
|
||||
// ─── Stat colour maps ─────────────────────────────────────────────────────────
|
||||
|
||||
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
|
||||
hunger: 'orange',
|
||||
happiness: 'yellow',
|
||||
health: 'green',
|
||||
hygiene: 'blue',
|
||||
energy: 'violet',
|
||||
};
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
orange: 'text-orange-500',
|
||||
yellow: 'text-yellow-500',
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
violet: 'text-violet-500',
|
||||
};
|
||||
|
||||
const STAT_BG_COLORS: Record<string, string> = {
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
green: 'bg-green-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
violet: 'bg-violet-500/10',
|
||||
};
|
||||
|
||||
const STAT_RING_HEX: Record<string, string> = {
|
||||
orange: '#f97316',
|
||||
yellow: '#eab308',
|
||||
green: '#22c55e',
|
||||
blue: '#3b82f6',
|
||||
violet: '#8b5cf6',
|
||||
};
|
||||
|
||||
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
|
||||
hunger: Utensils,
|
||||
happiness: Gamepad2,
|
||||
health: Heart,
|
||||
hygiene: Droplets,
|
||||
energy: Zap,
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiRoomHeroProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
className?: string;
|
||||
hideStats?: boolean;
|
||||
hideName?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRoomHeroProps) {
|
||||
const {
|
||||
companion,
|
||||
currentStats,
|
||||
isSleeping,
|
||||
isEgg,
|
||||
statusRecipe,
|
||||
statusRecipeLabel,
|
||||
effectiveEmotion,
|
||||
hasDevOverride,
|
||||
blobbiReaction,
|
||||
isActiveFloatingCompanion,
|
||||
isUpdatingCompanion,
|
||||
handleSetAsCompanion,
|
||||
heroRef,
|
||||
heroWidth,
|
||||
} = ctx;
|
||||
|
||||
if (isActiveFloatingCompanion) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
|
||||
<Footprints className="size-12 text-muted-foreground/30" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{companion.name} is out exploring right now.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSetAsCompanion}
|
||||
disabled={isUpdatingCompanion}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
isUpdatingCompanion && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
|
||||
>
|
||||
{isUpdatingCompanion ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Footprints className="size-4" />
|
||||
)}
|
||||
<span>Bring {companion.name} home</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
// No overflow-hidden — let the room own the visual surface.
|
||||
// pt-10 creates clearance for the floating room header overlay.
|
||||
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
{/* Stats crown */}
|
||||
{!hideStats && <StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />}
|
||||
|
||||
{/* Blobbi visual */}
|
||||
<div
|
||||
className="relative transition-all duration-500"
|
||||
style={!isSleeping ? {
|
||||
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
|
||||
} : undefined}
|
||||
>
|
||||
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated={!isSleeping}
|
||||
reaction={blobbiReaction}
|
||||
recipe={hasDevOverride ? undefined : statusRecipe}
|
||||
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
|
||||
emotion={effectiveEmotion}
|
||||
className={isEgg
|
||||
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
|
||||
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blobbi Name */}
|
||||
{!hideName && !isEgg && (
|
||||
<h2
|
||||
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
|
||||
style={{ color: companion.visualTraits.baseColor }}
|
||||
>
|
||||
{companion.name}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stats Crown ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsCrown({
|
||||
companion,
|
||||
currentStats,
|
||||
heroWidth,
|
||||
}: {
|
||||
companion: BlobbiRoomContext['companion'];
|
||||
currentStats: BlobbiRoomContext['currentStats'];
|
||||
heroWidth: number;
|
||||
}) {
|
||||
const allStats = useMemo(() =>
|
||||
getVisibleStats(companion.stage).map(stat => ({
|
||||
stat,
|
||||
value: currentStats[stat] ?? 100,
|
||||
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
|
||||
color: STAT_COLOR_MAP[stat],
|
||||
})),
|
||||
[companion.stage, currentStats]);
|
||||
|
||||
if (allStats.length === 0) return null;
|
||||
|
||||
const count = allStats.length;
|
||||
const isSmall = heroWidth < 400;
|
||||
|
||||
// Balanced arc: mobile is compact, desktop has moderate breathing room.
|
||||
// These values produce a stable crown with no dramatic changes between
|
||||
// 375px (mobile) and 640px+ (desktop) — a smooth interpolation.
|
||||
const arcSpread = isSmall
|
||||
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
|
||||
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
|
||||
const arcHalf = arcSpread / 2;
|
||||
const angles = count === 1
|
||||
? [0]
|
||||
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
|
||||
{allStats.map((s, i) => {
|
||||
const angleDeg = angles[i];
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
// Smooth interpolation: 110px at 340px width → 200px at 640px+ width
|
||||
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
|
||||
const x = Math.sin(angleRad) * radius;
|
||||
const y = Math.cos(angleRad) * radius - radius;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.stat}
|
||||
className="absolute transition-all duration-500"
|
||||
style={{
|
||||
transform: `translate(-50%, 0)`,
|
||||
left: `calc(50% + ${x.toFixed(1)}px)`,
|
||||
bottom: `${y.toFixed(1)}px`,
|
||||
}}
|
||||
>
|
||||
<StatIndicator
|
||||
stat={s.stat}
|
||||
value={s.value}
|
||||
color={s.color}
|
||||
status={s.status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
interface StatIndicatorProps {
|
||||
stat: string;
|
||||
value: number | undefined;
|
||||
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
status?: 'normal' | 'warning' | 'critical';
|
||||
}
|
||||
|
||||
function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorProps) {
|
||||
const displayValue = value ?? 0;
|
||||
const isLow = status === 'warning' || status === 'critical';
|
||||
const ringHex = STAT_RING_HEX[color];
|
||||
const IconComponent = STAT_ICON_MAP[stat];
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
|
||||
STAT_BG_COLORS[color],
|
||||
status === 'critical' && 'animate-pulse',
|
||||
)}>
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
|
||||
stroke={ringHex}
|
||||
strokeDasharray={`${displayValue * 0.94} 100`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="relative">
|
||||
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
|
||||
{isLow && (
|
||||
<AlertTriangle
|
||||
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// src/blobbi/rooms/components/BlobbiRoomShell.tsx
|
||||
|
||||
/**
|
||||
* BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard.
|
||||
*
|
||||
* Manages:
|
||||
* - Current room state + navigation
|
||||
* - Sleep dark overlay (scoped to this shell only)
|
||||
* - Ephemeral poop instances (local-only, no persistence)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, type CSSProperties } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
type BlobbiRoomId,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
DEFAULT_INITIAL_ROOM,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
getRoomIndex,
|
||||
} from '../lib/room-config';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import {
|
||||
generateInitialPoops,
|
||||
removePoop,
|
||||
type PoopInstance,
|
||||
} from '../lib/poop-system';
|
||||
|
||||
import { BlobbiHomeRoom } from './BlobbiHomeRoom';
|
||||
import { BlobbiKitchenRoom } from './BlobbiKitchenRoom';
|
||||
import { BlobbiCareRoom } from './BlobbiCareRoom';
|
||||
import { BlobbiHatcheryRoom } from './BlobbiHatcheryRoom';
|
||||
import { BlobbiRestRoom } from './BlobbiRestRoom';
|
||||
import { BlobbiClosetRoom } from './BlobbiClosetRoom';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiRoomShellProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
roomOrder?: BlobbiRoomId[];
|
||||
initialRoom?: BlobbiRoomId;
|
||||
}
|
||||
|
||||
interface RoomNavState {
|
||||
current: BlobbiRoomId;
|
||||
direction: 'left' | 'right' | null;
|
||||
}
|
||||
|
||||
// ─── Room Component Map ───────────────────────────────────────────────────────
|
||||
|
||||
const ROOM_COMPONENTS: Record<BlobbiRoomId, React.ComponentType<{ ctx: BlobbiRoomContext; poopState: RoomPoopState }>> = {
|
||||
care: BlobbiCareRoom,
|
||||
kitchen: BlobbiKitchenRoom,
|
||||
home: BlobbiHomeRoom,
|
||||
hatchery: BlobbiHatcheryRoom,
|
||||
rest: BlobbiRestRoom,
|
||||
closet: BlobbiClosetRoom,
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiRoomShell({
|
||||
ctx,
|
||||
roomOrder = DEFAULT_ROOM_ORDER,
|
||||
initialRoom = DEFAULT_INITIAL_ROOM,
|
||||
}: BlobbiRoomShellProps) {
|
||||
const [nav, setNav] = useState<RoomNavState>({
|
||||
current: roomOrder.includes(initialRoom) ? initialRoom : roomOrder[0],
|
||||
direction: null,
|
||||
});
|
||||
|
||||
const goRight = useCallback(() => {
|
||||
setNav(prev => ({
|
||||
current: getNextRoom(prev.current, roomOrder),
|
||||
direction: 'right',
|
||||
}));
|
||||
}, [roomOrder]);
|
||||
|
||||
const goLeft = useCallback(() => {
|
||||
setNav(prev => ({
|
||||
current: getPreviousRoom(prev.current, roomOrder),
|
||||
direction: 'left',
|
||||
}));
|
||||
}, [roomOrder]);
|
||||
|
||||
const meta = ROOM_META[nav.current];
|
||||
const roomIndex = getRoomIndex(nav.current, roomOrder);
|
||||
const RoomComponent = ROOM_COMPONENTS[nav.current];
|
||||
|
||||
const dots = useMemo(() => roomOrder.map((id, i) => ({
|
||||
id,
|
||||
active: i === roomIndex,
|
||||
label: ROOM_META[id].label,
|
||||
})), [roomOrder, roomIndex]);
|
||||
|
||||
// ─── Destination labels for nav arrows ───
|
||||
const leftDest = ROOM_META[getPreviousRoom(nav.current, roomOrder)];
|
||||
const rightDest = ROOM_META[getNextRoom(nav.current, roomOrder)];
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const isSleeping = ctx.isSleeping;
|
||||
|
||||
// ─── Poop system (ephemeral, local-only) ───
|
||||
const [poops, setPoops] = useState<PoopInstance[]>([]);
|
||||
const [shovelMode, setShovelMode] = useState(false);
|
||||
|
||||
// Generate poop on mount
|
||||
useEffect(() => {
|
||||
const hunger = ctx.currentStats.hunger;
|
||||
const lastFeed = ctx.lastFeedTimestamp ?? ctx.companion.lastInteraction * 1000;
|
||||
setPoops(generateInitialPoops(hunger, lastFeed));
|
||||
// Only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onRemovePoop = useCallback((poopId: string) => {
|
||||
setPoops(prev => {
|
||||
const { remaining, xpReward } = removePoop(prev, poopId);
|
||||
if (xpReward > 0) {
|
||||
toast({ title: `+${xpReward} XP`, description: 'Cleaned up!' });
|
||||
}
|
||||
if (remaining.length === 0) {
|
||||
setShovelMode(false);
|
||||
}
|
||||
return remaining;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const poopState: RoomPoopState = useMemo(() => ({
|
||||
poops,
|
||||
shovelMode,
|
||||
setShovelMode,
|
||||
onRemovePoop,
|
||||
}), [poops, shovelMode, onRemovePoop]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 relative">
|
||||
{/* ── Room Content — fills the entire shell ── */}
|
||||
<div className="flex-1 min-h-0 flex flex-col relative">
|
||||
<RoomComponent ctx={ctx} poopState={poopState} />
|
||||
</div>
|
||||
|
||||
{/* ── Sleep overlay — darkens the room when Blobbi sleeps ── */}
|
||||
{isSleeping && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Floating Room Header ── */}
|
||||
<div className="absolute inset-x-0 top-0 z-30 pointer-events-none">
|
||||
<div className="flex flex-col items-center pt-2 pb-1">
|
||||
<div className="flex items-center gap-1.5 pointer-events-auto">
|
||||
<span className="text-sm">{meta.icon}</span>
|
||||
<span className="text-xs sm:text-sm font-semibold text-foreground/70">{meta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{dots.map(dot => (
|
||||
<div
|
||||
key={dot.id}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-300',
|
||||
dot.active
|
||||
? 'w-4 h-1 bg-primary'
|
||||
: 'w-1 h-1 bg-muted-foreground/20',
|
||||
)}
|
||||
title={dot.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Left / Right Navigation Arrows with destination labels ── */}
|
||||
<button
|
||||
onClick={goLeft}
|
||||
className={cn(
|
||||
'group absolute left-0 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center gap-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70',
|
||||
'transition-all duration-200 active:scale-95',
|
||||
'cursor-pointer select-none',
|
||||
'rounded-r-full pl-0.5 pr-1 py-1',
|
||||
'hover:bg-accent/40',
|
||||
)}
|
||||
aria-label={`Go to ${leftDest.label}`}
|
||||
>
|
||||
<ChevronLeft
|
||||
className="size-5 shrink-0 transition-transform duration-300 group-hover:scale-110"
|
||||
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium leading-none whitespace-nowrap',
|
||||
'transition-all duration-200',
|
||||
isMobile
|
||||
? 'max-w-[60px] opacity-60'
|
||||
: 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-70 group-focus-visible:max-w-[80px] group-focus-visible:opacity-70',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{leftDest.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goRight}
|
||||
className={cn(
|
||||
'group absolute right-0 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center gap-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70',
|
||||
'transition-all duration-200 active:scale-95',
|
||||
'cursor-pointer select-none',
|
||||
'rounded-l-full pr-0.5 pl-1 py-1',
|
||||
'hover:bg-accent/40',
|
||||
)}
|
||||
aria-label={`Go to ${rightDest.label}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium leading-none whitespace-nowrap',
|
||||
'transition-all duration-200',
|
||||
isMobile
|
||||
? 'max-w-[60px] opacity-60'
|
||||
: 'max-w-0 opacity-0 group-hover:max-w-[80px] group-hover:opacity-70 group-focus-visible:max-w-[80px] group-focus-visible:opacity-70',
|
||||
'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{rightDest.label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="size-5 shrink-0 transition-transform duration-300 group-hover:scale-110"
|
||||
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// src/blobbi/rooms/components/ItemCarousel.tsx
|
||||
|
||||
/**
|
||||
* ItemCarousel — Single-focus carousel for room items.
|
||||
*
|
||||
* Layout stability guarantees:
|
||||
* - The entire carousel width is deterministic (arrows + previews + focus slot)
|
||||
* - Focused item uses a fixed-size container with overflow-hidden
|
||||
* - Label is clamped to a fixed max-width and single line
|
||||
* - Switching items never causes reflow or arrow movement
|
||||
*
|
||||
* Mobile: focused item only + compact arrows (no prev/next previews)
|
||||
* Desktop: focused item + translucent prev/next previews + arrows
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CarouselEntry {
|
||||
id: string;
|
||||
/** Emoji string or ReactNode rendered at large size */
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
/** Optional metadata attached to the entry (e.g. item type) */
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface ItemCarouselProps {
|
||||
items: CarouselEntry[];
|
||||
/** Called when the user taps the focused item */
|
||||
onUse: (id: string) => void;
|
||||
/** Item id currently being used (shows spinner) */
|
||||
activeItemId?: string | null;
|
||||
/** Whether any action is in progress */
|
||||
disabled?: boolean;
|
||||
/** Called when the focused item changes (for conditional side actions) */
|
||||
onFocusChange?: (entry: CarouselEntry) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ItemCarousel({
|
||||
items,
|
||||
onUse,
|
||||
activeItemId,
|
||||
disabled,
|
||||
onFocusChange,
|
||||
className,
|
||||
}: ItemCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const count = items.length;
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i - 1 + count) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i + 1) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
// Empty state matches the height of a populated carousel
|
||||
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
|
||||
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = items[index];
|
||||
const prevItem = items[(index - 1 + count) % count];
|
||||
const nextItem = items[(index + 1) % count];
|
||||
const isThisActive = activeItemId === current.id;
|
||||
const showPreviews = count >= 3;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
{/* Left arrow — fixed 28/32px */}
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Preview (prev) — desktop only, fixed 40x48px slot */}
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{prevItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focused item — FIXED 80x72 / 96x88 container, never resizes */}
|
||||
<button
|
||||
onClick={() => onUse(current.id)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
|
||||
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
|
||||
'transition-colors duration-200',
|
||||
'hover:bg-accent/20 active:scale-95',
|
||||
isThisActive && 'bg-accent/40',
|
||||
disabled && !isThisActive && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<span className="text-4xl sm:text-5xl leading-none">
|
||||
{current.icon}
|
||||
</span>
|
||||
{/* Label: fixed max-width, single line, ellipsis */}
|
||||
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
|
||||
{current.label}
|
||||
</span>
|
||||
{isThisActive && (
|
||||
<Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Preview (next) — desktop only, fixed 40x48px slot */}
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{nextItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right arrow — fixed 28/32px */}
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Next item"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// src/blobbi/rooms/components/RoomActionButton.tsx
|
||||
|
||||
/**
|
||||
* RoomActionButton — Unified circular action button for all rooms.
|
||||
*
|
||||
* Responsive sizing:
|
||||
* - Mobile: size-14 circle, size-7 icons
|
||||
* - Desktop (sm+): size-20 circle, size-9 icons
|
||||
*
|
||||
* Matches the soft radial glow of the original Photo / Companion buttons
|
||||
* but at a smaller scale so the bottom bar feels proportional on mobile.
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RoomActionButtonProps {
|
||||
/** Lucide icon or emoji element rendered inside the circle */
|
||||
icon: React.ReactNode;
|
||||
/** Small text label below the circle */
|
||||
label: string;
|
||||
/** CSS colour class applied to the icon (e.g. 'text-pink-500') */
|
||||
color: string;
|
||||
/** Hex colour used for the radial glow background */
|
||||
glowHex: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/** Optional badge content rendered at top-right of the circle */
|
||||
badge?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RoomActionButton({
|
||||
icon,
|
||||
label,
|
||||
color,
|
||||
glowHex,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
badge,
|
||||
className,
|
||||
}: RoomActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
|
||||
'hover:-translate-y-1 hover:scale-110 active:scale-95',
|
||||
disabled && 'opacity-50 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-7 sm:size-9 animate-spin" />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</div>
|
||||
{badge && (
|
||||
<div className="absolute -top-0.5 -right-0.5">
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// src/blobbi/rooms/index.ts — barrel export
|
||||
|
||||
export {
|
||||
type BlobbiRoomId,
|
||||
type BlobbiRoomMeta,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
DEFAULT_INITIAL_ROOM,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
getRoomIndex,
|
||||
} from './lib/room-config';
|
||||
|
||||
export { BlobbiRoomShell } from './components/BlobbiRoomShell';
|
||||
export { BlobbiHomeRoom } from './components/BlobbiHomeRoom';
|
||||
export { BlobbiKitchenRoom } from './components/BlobbiKitchenRoom';
|
||||
export { BlobbiCareRoom } from './components/BlobbiCareRoom';
|
||||
export { BlobbiHatcheryRoom } from './components/BlobbiHatcheryRoom';
|
||||
export { BlobbiRestRoom } from './components/BlobbiRestRoom';
|
||||
export { BlobbiClosetRoom } from './components/BlobbiClosetRoom';
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/blobbi/rooms/lib/poop-system.ts
|
||||
|
||||
/**
|
||||
* Temporary local-only poop system.
|
||||
*
|
||||
* Generates poop based on:
|
||||
* A) Overfeeding: hunger >= 95 -> poop in kitchen
|
||||
* B) Time elapsed: every 2 hours since last feed -> poop in a random room
|
||||
*
|
||||
* This is entirely ephemeral -- no persistence to Nostr or localStorage.
|
||||
* The state is generated fresh on page load and managed in React state.
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from './room-config';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoopInstance {
|
||||
id: string;
|
||||
room: BlobbiRoomId;
|
||||
/** 'overfeed' poops are kitchen-only and disappear on room change */
|
||||
source: 'overfeed' | 'time';
|
||||
/** Timestamp when this poop was generated */
|
||||
createdAt: number;
|
||||
/**
|
||||
* Safe-zone position for this poop.
|
||||
* Kept as % offsets so the layout stays responsive.
|
||||
* Positions are in the lower-left and lower-right corners,
|
||||
* avoiding the central Blobbi hero area.
|
||||
*/
|
||||
position: { bottom: number; left: number };
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OVERFEED_THRESHOLD = 95;
|
||||
const HOURS_PER_POOP = 2;
|
||||
const XP_PER_POOP = 5;
|
||||
|
||||
/** Rooms where time-based poop can appear (not closet) */
|
||||
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'hatchery', 'rest'];
|
||||
|
||||
/**
|
||||
* Pre-defined safe positions in the lower corners of the room.
|
||||
* Values are percentages. These avoid the central hero area
|
||||
* (roughly 30%–70% horizontal, above 35% vertical).
|
||||
*/
|
||||
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
|
||||
{ bottom: 22, left: 8 }, // lower-left
|
||||
{ bottom: 18, left: 78 }, // lower-right
|
||||
{ bottom: 28, left: 14 }, // mid-left
|
||||
{ bottom: 25, left: 82 }, // mid-right
|
||||
{ bottom: 15, left: 20 }, // bottom-left-ish
|
||||
{ bottom: 20, left: 72 }, // bottom-right-ish
|
||||
];
|
||||
|
||||
// ─── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
let _idCounter = 0;
|
||||
function nextPoopId(): string {
|
||||
return `poop_${++_idCounter}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function pickPosition(index: number): { bottom: number; left: number } {
|
||||
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate initial poop instances based on current companion state.
|
||||
* Called once when the dashboard mounts.
|
||||
*/
|
||||
export function generateInitialPoops(
|
||||
hunger: number,
|
||||
lastFeedTimestamp: number | undefined,
|
||||
): PoopInstance[] {
|
||||
const poops: PoopInstance[] = [];
|
||||
const now = Date.now();
|
||||
let posIndex = 0;
|
||||
|
||||
// A) Overfeeding poop -- kitchen only
|
||||
if (hunger >= OVERFEED_THRESHOLD) {
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room: 'kitchen',
|
||||
source: 'overfeed',
|
||||
createdAt: now,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
|
||||
// B) Time-based poop -- random room
|
||||
if (lastFeedTimestamp) {
|
||||
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
|
||||
const poopCount = Math.floor(hoursSinceFeed / HOURS_PER_POOP);
|
||||
for (let i = 0; i < Math.min(poopCount, 3); i++) {
|
||||
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room,
|
||||
source: 'time',
|
||||
createdAt: now - i * 1000,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return poops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get poops visible in a specific room.
|
||||
*/
|
||||
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
|
||||
return poops.filter(p => p.room === room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a poop by id and return the XP reward.
|
||||
*/
|
||||
export function removePoop(
|
||||
poops: PoopInstance[],
|
||||
poopId: string,
|
||||
): { remaining: PoopInstance[]; xpReward: number } {
|
||||
const remaining = poops.filter(p => p.id !== poopId);
|
||||
const wasRemoved = remaining.length < poops.length;
|
||||
return {
|
||||
remaining,
|
||||
xpReward: wasRemoved ? XP_PER_POOP : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any poop exists anywhere.
|
||||
*/
|
||||
export function hasAnyPoop(poops: PoopInstance[]): boolean {
|
||||
return poops.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// src/blobbi/rooms/lib/room-config.ts
|
||||
|
||||
/**
|
||||
* Blobbi Room System — Configuration & Navigation
|
||||
*
|
||||
* This module defines the room types, default ordering, and navigation helpers.
|
||||
* The design supports future per-user customisation: the default order is data,
|
||||
* not hardcoded control flow, so it can be replaced with a user-stored sequence.
|
||||
*/
|
||||
|
||||
// ─── Room IDs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unique identifier for each room in the Blobbi world.
|
||||
* New rooms can be added here without breaking existing code.
|
||||
*/
|
||||
export type BlobbiRoomId = 'care' | 'kitchen' | 'home' | 'hatchery' | 'rest' | 'closet';
|
||||
|
||||
// ─── Room Metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiRoomMeta {
|
||||
/** Unique room identifier */
|
||||
id: BlobbiRoomId;
|
||||
/** Human-readable display label */
|
||||
label: string;
|
||||
/** Short description (for tooltips / accessibility) */
|
||||
description: string;
|
||||
/** Emoji icon representing the room */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static metadata for every room.
|
||||
* This is a lookup — order does NOT matter here.
|
||||
*/
|
||||
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
|
||||
care: {
|
||||
id: 'care',
|
||||
label: 'Care Room',
|
||||
description: 'Hygiene, care, and medicine',
|
||||
icon: '🩹',
|
||||
},
|
||||
kitchen: {
|
||||
id: 'kitchen',
|
||||
label: 'Kitchen',
|
||||
description: 'Feed your Blobbi',
|
||||
icon: '🍳',
|
||||
},
|
||||
home: {
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
description: 'Main living room',
|
||||
icon: '🏠',
|
||||
},
|
||||
hatchery: {
|
||||
id: 'hatchery',
|
||||
label: 'Hatchery',
|
||||
description: 'Evolution and quests',
|
||||
icon: '🥚',
|
||||
},
|
||||
rest: {
|
||||
id: 'rest',
|
||||
label: 'Bedroom',
|
||||
description: 'Rest and recharge',
|
||||
icon: '🌙',
|
||||
},
|
||||
closet: {
|
||||
id: 'closet',
|
||||
label: 'Closet',
|
||||
description: 'Wardrobe and accessories',
|
||||
icon: '👗',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Default Room Order ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The default room sequence.
|
||||
*
|
||||
* IMPORTANT: This array is the ONLY place that defines order.
|
||||
* To support per-user customisation later, replace this with
|
||||
* a user-stored array of BlobbiRoomId values.
|
||||
*
|
||||
* Closet is excluded for now (not yet implemented).
|
||||
* To re-enable, add 'closet' back to the array.
|
||||
*/
|
||||
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
|
||||
'care',
|
||||
'kitchen',
|
||||
'home',
|
||||
'hatchery',
|
||||
'rest',
|
||||
// 'closet', — re-enable when wardrobe feature is ready
|
||||
];
|
||||
|
||||
/**
|
||||
* The room that should be selected when the dashboard first loads.
|
||||
*/
|
||||
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
|
||||
|
||||
// ─── Navigation Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the next room in a looping sequence.
|
||||
*
|
||||
* @param current - The currently active room
|
||||
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
|
||||
* @returns The next room id (wraps around)
|
||||
*/
|
||||
export function getNextRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[0];
|
||||
return order[(idx + 1) % order.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous room in a looping sequence.
|
||||
*
|
||||
* @param current - The currently active room
|
||||
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
|
||||
* @returns The previous room id (wraps around)
|
||||
*/
|
||||
export function getPreviousRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[order.length - 1];
|
||||
return order[(idx - 1 + order.length) % order.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index of a room in the order array.
|
||||
* Returns -1 if the room is not in the order.
|
||||
*/
|
||||
export function getRoomIndex(
|
||||
room: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): number {
|
||||
return order.indexOf(room);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// src/blobbi/rooms/lib/room-layout.ts
|
||||
|
||||
/**
|
||||
* Shared layout constants for Blobbi room components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CSS class for the bottom action bar in every room.
|
||||
*
|
||||
* On mobile/tablet (max-sidebar), adds extra bottom padding so the
|
||||
* room controls clear the app's fixed bottom navigation bar.
|
||||
* On desktop (sidebar:), uses normal padding since there's no bottom nav.
|
||||
*/
|
||||
export const ROOM_BOTTOM_BAR_CLASS =
|
||||
'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]';
|
||||
@@ -0,0 +1,196 @@
|
||||
// src/blobbi/rooms/lib/room-types.ts
|
||||
|
||||
/**
|
||||
* Shared prop types for Blobbi room components.
|
||||
*
|
||||
* These types are the "contract" that the BlobbiDashboard passes down
|
||||
* to each room. They mirror the existing BlobbiDashboard internal state
|
||||
* so rooms can reuse all existing logic without duplication.
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type {
|
||||
InventoryAction,
|
||||
DirectAction,
|
||||
InlineActivityState,
|
||||
BlobbiReactionState,
|
||||
SelectedTrack,
|
||||
StartIncubationMode,
|
||||
} from '@/blobbi/actions';
|
||||
import type { useHatchTasks, useEvolveTasks, useDailyMissions } from '@/blobbi/actions';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { ShopItem } from '@/blobbi/shop/types/shop.types';
|
||||
|
||||
// ─── Shared Dashboard Context ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a room needs from the dashboard.
|
||||
* Passed down by BlobbiRoomShell so rooms don't import dashboard state directly.
|
||||
*/
|
||||
export interface BlobbiRoomContext {
|
||||
// ── Core data ──
|
||||
companion: BlobbiCompanion;
|
||||
companions: BlobbiCompanion[];
|
||||
selectedD: string;
|
||||
profile: BlobbonautProfile | null;
|
||||
|
||||
// ── Projected / visual state ──
|
||||
currentStats: {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
};
|
||||
isSleeping: boolean;
|
||||
isEgg: boolean;
|
||||
isBaby: boolean;
|
||||
|
||||
// ── Visual recipe ──
|
||||
statusRecipe: BlobbiVisualRecipe | undefined;
|
||||
statusRecipeLabel: string | undefined;
|
||||
effectiveEmotion: BlobbiEmotion;
|
||||
hasDevOverride: boolean;
|
||||
blobbiReaction: BlobbiReactionState;
|
||||
|
||||
// ── Item use ──
|
||||
onUseItem: (itemId: string, action: InventoryAction) => Promise<void>;
|
||||
handleUseItemFromTab: (itemId: string) => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
allShopItems: ShopItem[];
|
||||
|
||||
// ── Direct actions ──
|
||||
onDirectAction: (action: DirectAction) => Promise<void>;
|
||||
handleDirectAction: (action: DirectAction) => void;
|
||||
isDirectActionPending: boolean;
|
||||
|
||||
// ── Inline activity (music/sing) ──
|
||||
inlineActivity: InlineActivityState;
|
||||
setInlineActivity: React.Dispatch<React.SetStateAction<InlineActivityState>>;
|
||||
setBlobbiReaction: React.Dispatch<React.SetStateAction<BlobbiReactionState>>;
|
||||
setActionOverrideEmotion: React.Dispatch<React.SetStateAction<BlobbiEmotion | null>>;
|
||||
showTrackPickerModal: boolean;
|
||||
setShowTrackPickerModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleTrackSelected: (selection: SelectedTrack) => Promise<void>;
|
||||
handleConfirmSing: () => Promise<void>;
|
||||
handleCloseInlineActivity: () => void;
|
||||
handleMusicPlaybackStart: () => void;
|
||||
handleMusicPlaybackStop: () => void;
|
||||
handleSingRecordingStart: () => void;
|
||||
handleSingRecordingStop: () => void;
|
||||
handleChangeTrack: () => void;
|
||||
|
||||
// ── Rest / sleep ──
|
||||
onRest: () => void;
|
||||
actionInProgress: string | null;
|
||||
isPublishing: boolean;
|
||||
|
||||
// ── Companion toggle ──
|
||||
isCurrentCompanion: boolean;
|
||||
canBeCompanion: boolean;
|
||||
isUpdatingCompanion: boolean;
|
||||
isActiveFloatingCompanion: boolean;
|
||||
handleSetAsCompanion: () => Promise<void>;
|
||||
|
||||
// ── Photo ──
|
||||
showPhotoModal: boolean;
|
||||
setShowPhotoModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// ── Blobbi selector ──
|
||||
onSelectBlobbi: (d: string) => void;
|
||||
|
||||
// ── Incubation / Evolution / Tasks ──
|
||||
isIncubating: boolean;
|
||||
isEvolvingState: boolean;
|
||||
canStartIncubation: boolean;
|
||||
canStartEvolution: boolean;
|
||||
isStartingIncubation: boolean;
|
||||
isStartingEvolution: boolean;
|
||||
isStoppingIncubation: boolean;
|
||||
isStoppingEvolution: boolean;
|
||||
isHatching: boolean;
|
||||
isEvolving: boolean;
|
||||
hatchTasks: ReturnType<typeof useHatchTasks>;
|
||||
evolveTasks: ReturnType<typeof useEvolveTasks>;
|
||||
onStartIncubation: (mode: StartIncubationMode, stopOtherD?: string) => Promise<void>;
|
||||
onStartEvolution: () => Promise<void>;
|
||||
onStopIncubation: () => Promise<void>;
|
||||
onStopEvolution: () => Promise<void>;
|
||||
onHatch: () => Promise<void>;
|
||||
onEvolve: () => Promise<void>;
|
||||
showPostModal: boolean;
|
||||
setShowPostModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refetchCurrentTasks: () => void;
|
||||
|
||||
// ── Daily missions ──
|
||||
dailyMissions: ReturnType<typeof useDailyMissions>;
|
||||
onClaimReward: (id: string) => void;
|
||||
isClaimingReward: boolean;
|
||||
availableStages: ('egg' | 'baby' | 'adult')[];
|
||||
|
||||
// ── Adoption ──
|
||||
showAdoptionFlow: boolean;
|
||||
setShowAdoptionFlow: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// ── Adoption + Profile update props ──
|
||||
publishEvent: (params: { kind: number; content: string; tags: string[][] }) => Promise<NostrEvent>;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: StorageItem[];
|
||||
} | null>;
|
||||
|
||||
// ── Naddr link ──
|
||||
blobbiNaddr: string;
|
||||
|
||||
// ── Hero measurement ──
|
||||
/** Callback ref for the hero container — re-attaches ResizeObserver on room switch */
|
||||
heroRef: React.RefCallback<HTMLDivElement> | React.RefObject<HTMLDivElement | null>;
|
||||
heroWidth: number;
|
||||
|
||||
// ── DEV ONLY ──
|
||||
showDevEditor: boolean;
|
||||
setShowDevEditor: (show: boolean) => void;
|
||||
onDevEditorApply: (updates: import('@/blobbi/dev').BlobbiDevUpdates) => Promise<void>;
|
||||
isDevUpdating: boolean;
|
||||
showEmotionPanel: boolean;
|
||||
setShowEmotionPanel: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showProgressionPanel: boolean;
|
||||
setShowProgressionPanel: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showHatchCeremony: boolean;
|
||||
setShowHatchCeremony: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// ── Inventory modal (still used in kitchen) ──
|
||||
inventoryAction: InventoryAction | null;
|
||||
setInventoryAction: React.Dispatch<React.SetStateAction<InventoryAction | null>>;
|
||||
|
||||
// ── Last feed timestamp (for poop system) ──
|
||||
lastFeedTimestamp: number | undefined;
|
||||
}
|
||||
|
||||
// ─── Poop State (passed from shell to rooms) ──────────────────────────────────
|
||||
|
||||
import type { PoopInstance } from './poop-system';
|
||||
|
||||
export interface RoomPoopState {
|
||||
/** All poop instances across rooms */
|
||||
poops: PoopInstance[];
|
||||
/** Whether shovel mode is currently active */
|
||||
shovelMode: boolean;
|
||||
/** Toggle shovel mode on/off */
|
||||
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Remove a poop (returns XP reward via callback) */
|
||||
onRemovePoop: (poopId: string) => void;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export function useBlobbiPurchaseItem(currentProfile: BlobbonautProfile | null)
|
||||
// Publish updated profile event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
content: currentProfile.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
|
||||
@@ -876,51 +876,3 @@ export const ACTION_EMOTION_MAP: Record<ActionType, BlobbiEmotion> = {
|
||||
export function getActionEmotion(action: ActionType): BlobbiEmotion {
|
||||
return ACTION_EMOTION_MAP[action];
|
||||
}
|
||||
|
||||
// ─── Feed Attenuation ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Produce a lighter version of a visual recipe suitable for feed cards.
|
||||
*
|
||||
* Feed Blobbis are rendered at a smaller size (size-48/56 vs size-64+) and
|
||||
* need to remain readable at a glance. This function keeps all facial parts
|
||||
* (eyes, mouth, eyebrows) and extras untouched — they are already sized
|
||||
* relative to the SVG viewBox — but reduces body-effect particle counts
|
||||
* and removes flies to prevent visual clutter at small sizes.
|
||||
*
|
||||
* The input recipe is produced by the same `resolveStatusRecipe()` used
|
||||
* by the room view, so thresholds and priorities are identical.
|
||||
*/
|
||||
export function attenuateRecipeForFeed(recipe: BlobbiVisualRecipe): BlobbiVisualRecipe {
|
||||
// Empty / no body effects → return as-is (stable reference path)
|
||||
if (!recipe.bodyEffects) return recipe;
|
||||
|
||||
const { bodyEffects, ...rest } = recipe;
|
||||
const attenuated: BodyEffectsRecipe = {};
|
||||
|
||||
// Dirt marks: reduce count by ~40%, lower intensity cap
|
||||
if (bodyEffects.dirtMarks?.enabled) {
|
||||
attenuated.dirtMarks = {
|
||||
...bodyEffects.dirtMarks,
|
||||
count: Math.max(1, Math.ceil((bodyEffects.dirtMarks.count ?? 3) * 0.6)),
|
||||
intensity: Math.min(bodyEffects.dirtMarks.intensity ?? 0.6, 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
// Stink clouds: reduce count, remove flies entirely
|
||||
if (bodyEffects.stinkClouds?.enabled) {
|
||||
attenuated.stinkClouds = {
|
||||
...bodyEffects.stinkClouds,
|
||||
count: Math.max(1, Math.ceil((bodyEffects.stinkClouds.count ?? 3) * 0.5)),
|
||||
flies: false,
|
||||
flyCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Anger rise: pass through unchanged (single overlay, scales with SVG)
|
||||
if (bodyEffects.angerRise) {
|
||||
attenuated.angerRise = bodyEffects.angerRise;
|
||||
}
|
||||
|
||||
return { ...rest, bodyEffects: attenuated };
|
||||
}
|
||||
|
||||
@@ -297,10 +297,11 @@ export function AdvancedSettings() {
|
||||
<div className="px-3 pt-3 pb-4 space-y-4">
|
||||
<div className="rounded-lg border border-destructive/30 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Delete Account</h3>
|
||||
<h3 className="text-sm font-medium">Request to Vanish</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Permanently delete your data from the network, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible.
|
||||
Permanently request all relays to delete your data, including your profile,
|
||||
posts, reactions, and direct messages. This action is irreversible and legally
|
||||
binding in some jurisdictions (NIP-62).
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -309,7 +310,7 @@ export function AdvancedSettings() {
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setVanishDialogOpen(true)}
|
||||
>
|
||||
Delete Account
|
||||
Request to Vanish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { NostrURI } from '@/lib/NostrURI';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Get a tag value by name. */
|
||||
@@ -107,7 +106,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
const about = metadata.about;
|
||||
const picture = metadata.picture;
|
||||
const banner = metadata.banner;
|
||||
const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata));
|
||||
const websiteUrl = getWebsiteUrl(event.tags, metadata);
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
|
||||
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
|
||||
|
||||
@@ -3,41 +3,17 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
|
||||
import { calculateProjectedDecay } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { resolveStatusRecipe, attenuateRecipeForFeed, EMPTY_RECIPE } from '@/blobbi/ui/lib/status-reactions';
|
||||
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
|
||||
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
|
||||
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
|
||||
|
||||
const isSleeping = companion?.state === 'sleeping';
|
||||
const isEgg = companion?.stage === 'egg';
|
||||
|
||||
// ── Project stats forward in time, then resolve visual recipe ──
|
||||
// Feed cards show a snapshot, not a live ticker, so we call the pure
|
||||
// calculateProjectedDecay() once per render instead of using the
|
||||
// interval-based useProjectedBlobbiState hook. This gives us the
|
||||
// same decay math the room view uses (applyBlobbiDecay under the
|
||||
// hood) without any per-card setInterval overhead.
|
||||
const { recipe: feedRecipe, recipeLabel: feedRecipeLabel } = useMemo(() => {
|
||||
if (!companion || isEgg) return { recipe: EMPTY_RECIPE, recipeLabel: 'neutral' };
|
||||
|
||||
const { stats } = calculateProjectedDecay(companion);
|
||||
|
||||
const result = resolveStatusRecipe(stats);
|
||||
|
||||
// Attenuate body effects for feed-card size, then apply sleep overlay
|
||||
const attenuated = attenuateRecipeForFeed(result.recipe);
|
||||
const final = isSleeping ? buildSleepingRecipe(attenuated) : attenuated;
|
||||
|
||||
return { recipe: final, recipeLabel: isSleeping ? 'sleeping' : result.label };
|
||||
}, [companion, isEgg, isSleeping]);
|
||||
|
||||
if (!companion) return null;
|
||||
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center py-4">
|
||||
{/* Blobbi visual — reflects current condition */}
|
||||
{/* Blobbi visual — same as /blobbi hero */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
|
||||
<BlobbiStageVisual
|
||||
@@ -45,8 +21,6 @@ export function BlobbiStateCard({ event }: { event: NostrEvent }) {
|
||||
size="lg"
|
||||
animated={!isSleeping}
|
||||
lookMode="forward"
|
||||
recipe={feedRecipe}
|
||||
recipeLabel={feedRecipeLabel}
|
||||
className="size-48 sm:size-56"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,6 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -160,7 +159,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -93,7 +92,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) {
|
||||
// Extract website URL from description if present
|
||||
const descriptionUrl = useMemo(() => {
|
||||
const urlMatch = description.match(/https?:\/\/[^\s]+/);
|
||||
return sanitizeUrl(urlMatch?.[0]);
|
||||
return urlMatch?.[0];
|
||||
}, [description]);
|
||||
|
||||
// Description text without trailing URL (if the URL is the last thing)
|
||||
|
||||
@@ -43,7 +43,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useInsertText } from '@/hooks/useInsertText';
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { resizeImage } from '@/lib/resizeImage';
|
||||
|
||||
@@ -1072,7 +1071,7 @@ export function ComposeBox({
|
||||
<Avatar shape={avatarShape} className="size-12 shrink-0 mt-0.5">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
@@ -292,9 +292,6 @@ export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommended aspect ratio is 1:1 (max 1024x1024 px).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge name */}
|
||||
|
||||
@@ -3,7 +3,6 @@ import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface EmojiData {
|
||||
id: string;
|
||||
@@ -187,14 +186,6 @@ export function EmojiShortcodeAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 280, // must match max-h-[280px] below
|
||||
});
|
||||
|
||||
const results = useMemo(() => searchEmojis(query, customEmojis), [query, customEmojis]);
|
||||
|
||||
// Detect :shortcode query at cursor
|
||||
@@ -246,11 +237,14 @@ export function EmojiShortcodeAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
// Position the dropdown below the : character
|
||||
const coords = getCaretCoordinates(textarea, colonPos);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element
|
||||
useEffect(() => {
|
||||
@@ -363,10 +357,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
const dropdown = (
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[280px] overflow-y-auto py-1">
|
||||
@@ -388,7 +382,7 @@ export function EmojiShortcodeAutocomplete({
|
||||
className="size-5 object-contain shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0 font-emoji">{emoji.native}</span>
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
)}
|
||||
<span className="text-sm truncate">
|
||||
:{emoji.id.replace('custom:', '')}:
|
||||
@@ -398,8 +392,4 @@ export function EmojiShortcodeAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
+40
-34
@@ -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 { DITTO_RELAYS } from '@/lib/appRelays';
|
||||
import { useInfiniteHotFeed } from '@/hooks/useTrending';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
@@ -22,15 +22,13 @@ 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';
|
||||
@@ -38,6 +36,23 @@ 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[];
|
||||
@@ -59,7 +74,6 @@ 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 = (() => {
|
||||
@@ -136,17 +150,21 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
|
||||
);
|
||||
|
||||
// Curated Ditto feed: latest content from the curator's follow list.
|
||||
const topQuery = useCuratedDittoFeed(
|
||||
curatorFollowList,
|
||||
// "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,
|
||||
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 ? ['ditto-curated-feed'] : ['feed', activeTab],
|
||||
() => useDittoQuery ? ['infinite-hot-feed', LANDING_KINDS.join(',')] : ['feed', activeTab],
|
||||
[useDittoQuery, activeTab],
|
||||
);
|
||||
|
||||
@@ -186,25 +204,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (useDittoQuery) {
|
||||
// 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 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 }));
|
||||
}
|
||||
|
||||
return (rawData.pages as unknown as { items: FeedItem[] }[])
|
||||
@@ -219,9 +228,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
});
|
||||
}, [rawData?.pages, muteItems, useDittoQuery]);
|
||||
|
||||
// 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);
|
||||
const showSkeleton = isPending || (isLoading && !rawData);
|
||||
|
||||
// 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.
|
||||
@@ -229,7 +236,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0 min-h-dvh">
|
||||
<main className="flex-1 min-w-0">
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero
|
||||
@@ -327,11 +334,10 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
message={
|
||||
emptyMessage ?? (
|
||||
activeTab === 'follows'
|
||||
? 'Your feed is empty. Follow some people to see their posts here.'
|
||||
? 'No posts yet. Follow some people to see their content here.'
|
||||
: 'No posts found. Check your relay connections or come back soon.'
|
||||
)
|
||||
}
|
||||
showDiscover={!emptyMessage && activeTab === 'follows'}
|
||||
onSwitchToGlobal={
|
||||
activeTab === 'follows' && showGlobalFeed
|
||||
? () => handleSetActiveTab('global')
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedEmptyStateProps {
|
||||
@@ -8,45 +5,31 @@ interface FeedEmptyStateProps {
|
||||
message: string;
|
||||
/** Called when the user clicks "Switch to Global". Omit to hide the button. */
|
||||
onSwitchToGlobal?: () => void;
|
||||
/** Show a "Discover people" link to /packs. */
|
||||
showDiscover?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent empty state for Follows/Global feed tabs across all feed pages.
|
||||
*
|
||||
* - Follows tab: pass `onSwitchToGlobal` and `showDiscover` to render CTAs.
|
||||
* - Global tab: omit both; the message should guide the user
|
||||
* - Follows tab: pass `onSwitchToGlobal` to render a "Switch to Global" CTA.
|
||||
* - Global tab: omit `onSwitchToGlobal`; the message should guide the user
|
||||
* to check their relay connections.
|
||||
*/
|
||||
export function FeedEmptyState({
|
||||
message,
|
||||
onSwitchToGlobal,
|
||||
showDiscover,
|
||||
className,
|
||||
}: FeedEmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('py-20 px-8 flex flex-col items-center text-center', className)}>
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Users className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground max-w-xs">{message}</p>
|
||||
|
||||
{(showDiscover || onSwitchToGlobal) && (
|
||||
<div className="flex flex-col gap-2 mt-5 w-full max-w-xs">
|
||||
{showDiscover && (
|
||||
<Button asChild className="rounded-full">
|
||||
<Link to="/packs">Discover people to follow</Link>
|
||||
</Button>
|
||||
)}
|
||||
{onSwitchToGlobal && (
|
||||
<Button variant="ghost" className="rounded-full" onClick={onSwitchToGlobal}>
|
||||
Browse the Global feed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('py-16 px-8 text-center space-y-3', className)}>
|
||||
<p className="text-muted-foreground break-all">{message}</p>
|
||||
{onSwitchToGlobal && (
|
||||
<button
|
||||
className="text-sm text-primary hover:underline"
|
||||
onClick={onSwitchToGlobal}
|
||||
>
|
||||
Switch to Global
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Extract the first value of a tag by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
@@ -76,7 +75,7 @@ interface FileMetadataContentProps {
|
||||
* rounded card below it (similar to YouTube's description box).
|
||||
*/
|
||||
export function FileMetadataContent({ event, compact }: FileMetadataContentProps) {
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
const url = getTag(event.tags, 'url');
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const alt = getTag(event.tags, 'alt');
|
||||
const webxdcId = getTag(event.tags, 'webxdc');
|
||||
|
||||
@@ -21,17 +21,26 @@ 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
const { posts, isLoading } = useStreamPosts('', {
|
||||
@@ -92,7 +101,7 @@ export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) {
|
||||
|
||||
// ─── Members Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function PackMembersTab({
|
||||
function PackMembersTab({
|
||||
pubkeys,
|
||||
membersMap,
|
||||
membersLoading,
|
||||
@@ -177,32 +186,34 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
setIsFollowingAll(true);
|
||||
try {
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
|
||||
// 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 followEvents = await nostr.query(
|
||||
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// 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;
|
||||
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));
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: prev?.content ?? '',
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
content: latestEvent?.content ?? '',
|
||||
tags: allFollows.map((pk) => ['p', pk]),
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Following all!',
|
||||
description: added > 0
|
||||
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
|
||||
description: added.length > 0
|
||||
? `Added ${added.length} new account${added.length !== 1 ? 's' : ''} to your follow list.`
|
||||
: 'You were already following everyone in this pack.',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -346,7 +357,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
|
||||
/** Individual member card in the follow pack. */
|
||||
export function MemberCard({
|
||||
function MemberCard({
|
||||
pubkey,
|
||||
metadata,
|
||||
isFollowed,
|
||||
@@ -426,7 +437,7 @@ export function MemberCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberCardSkeleton() {
|
||||
function MemberCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
|
||||
@@ -9,7 +9,125 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
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;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { BookMarked, Copy, Check, ExternalLink, Globe, Wand2 } from "lucide-reac
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openUrl } from "@/lib/downloadFile";
|
||||
import { sanitizeUrl } from "@/lib/sanitizeUrl";
|
||||
import { NostrURI } from "@/lib/NostrURI";
|
||||
|
||||
interface GitRepoCardProps {
|
||||
@@ -24,7 +23,7 @@ function getFaviconUrl(webUrl: string): string | undefined {
|
||||
export function GitRepoCard({ event }: GitRepoCardProps) {
|
||||
const name = event.tags.find(([n]) => n === "name")?.[1];
|
||||
const description = event.tags.find(([n]) => n === "description")?.[1];
|
||||
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v);
|
||||
const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v);
|
||||
const isPersonalFork = event.tags.some(
|
||||
([n, v]) => n === "t" && v === "personal-fork",
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { NostrEvent, NostrMetadata } from "@nostrify/nostrify";
|
||||
import { useNostr } from "@nostrify/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Heart,
|
||||
@@ -12,8 +14,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
|
||||
import { saveNsec } from "@/lib/credentialManager";
|
||||
import { fetchFreshEvent } from "@/lib/fetchFreshEvent";
|
||||
import { downloadTextFile } from "@/lib/downloadFile";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -44,7 +45,6 @@ import { toast } from "@/hooks/useToast";
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
import { genUserName } from "@/lib/genUserName";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { resolveTheme, resolveThemeConfig } from "@/themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -288,8 +288,7 @@ function SetupQuestionnaire({
|
||||
}
|
||||
}, [step, steps]);
|
||||
|
||||
// Keygen handler — generates the key and advances to the save step.
|
||||
// The credential manager prompt is deferred until the user clicks "Continue".
|
||||
// Keygen handler
|
||||
const handleGenerate = useCallback(() => {
|
||||
const sk = generateSecretKey();
|
||||
const encoded = nip19.nsecEncode(sk);
|
||||
@@ -297,26 +296,31 @@ function SetupQuestionnaire({
|
||||
next();
|
||||
}, [next]);
|
||||
|
||||
// Continue handler for the download step — saves the key via the best
|
||||
// available method (native credential manager on iOS/Android, file download
|
||||
// on web), logs in, and advances to the next step.
|
||||
const handleDownloadContinue = useCallback(async () => {
|
||||
// Download + login handler
|
||||
const handleDownloadAndLogin = useCallback(async () => {
|
||||
try {
|
||||
const decoded = nip19.decode(nsec);
|
||||
if (decoded.type !== "nsec") throw new Error("Invalid nsec");
|
||||
|
||||
const pubkey = getPublicKey(decoded.data);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const filename = `nostr-${location.hostname.replaceAll(/\./g, "-")}-${npub.slice(5, 9)}.nsec.txt`;
|
||||
|
||||
await saveNsec(npub, nsec);
|
||||
await downloadTextFile(filename, nsec);
|
||||
|
||||
// Let the user know where the file ended up on Android
|
||||
if (Capacitor.getPlatform() === "android") {
|
||||
toast({ title: "Key saved", description: `Saved to Download/${filename}` });
|
||||
}
|
||||
|
||||
// Log in with the new key
|
||||
login.nsec(nsec);
|
||||
next();
|
||||
} catch {
|
||||
toast({
|
||||
title: "Save failed",
|
||||
title: "Download failed",
|
||||
description:
|
||||
"Could not save the key. Please copy it manually.",
|
||||
"Could not download the key file. Please copy it manually.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
@@ -448,7 +452,7 @@ function SetupQuestionnaire({
|
||||
{step === "keygen" && <KeygenStep onGenerate={handleGenerate} />}
|
||||
|
||||
{step === "download" && (
|
||||
<DownloadStep nsec={nsec} onContinue={handleDownloadContinue} />
|
||||
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
|
||||
)}
|
||||
|
||||
{step === "profile" && (
|
||||
@@ -515,10 +519,10 @@ function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
||||
|
||||
function DownloadStep({
|
||||
nsec,
|
||||
onContinue,
|
||||
onDownload,
|
||||
}: {
|
||||
nsec: string;
|
||||
onContinue: () => void;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
@@ -529,7 +533,8 @@ function DownloadStep({
|
||||
Save your secret key
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This is your only way to access your account. Keep it somewhere safe.
|
||||
This is your only way to access your account. Download it and keep it
|
||||
somewhere safe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -561,17 +566,17 @@ function DownloadStep({
|
||||
</p>
|
||||
<p className="text-xs text-amber-900 dark:text-amber-300">
|
||||
This key is your only means of accessing your account. If you lose it,
|
||||
there is no way to recover it.
|
||||
there is no way to recover it. Download it now to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full gap-2 rounded-full h-12"
|
||||
onClick={onContinue}
|
||||
onClick={onDownload}
|
||||
>
|
||||
Continue
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Download className="w-4 h-4" />
|
||||
Download and continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -599,6 +604,9 @@ function ProfileStep({
|
||||
banner: "",
|
||||
website: "",
|
||||
});
|
||||
const [extraFields, setExtraFields] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([]);
|
||||
const [cropState, setCropState] = useState<{
|
||||
imageSrc: string;
|
||||
aspect: number;
|
||||
@@ -653,10 +661,17 @@ function ProfileStep({
|
||||
|
||||
const handlePublishProfile = useCallback(async () => {
|
||||
if (!user) return;
|
||||
const hasData = Object.values(profileData).some((v) => v);
|
||||
const hasData =
|
||||
Object.values(profileData).some((v) => v) || extraFields.length > 0;
|
||||
if (hasData) {
|
||||
try {
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(profileData), tags: [] });
|
||||
const data: Record<string, unknown> = { ...profileData };
|
||||
const validFields = extraFields.filter(
|
||||
(f) => f.label.trim() && f.value.trim(),
|
||||
);
|
||||
if (validFields.length > 0)
|
||||
data.fields = validFields.map((f) => [f.label, f.value]);
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(data), tags: [] });
|
||||
queryClient.invalidateQueries({ queryKey: ["logins"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
|
||||
} catch {
|
||||
@@ -669,7 +684,7 @@ function ProfileStep({
|
||||
}
|
||||
}
|
||||
onNext();
|
||||
}, [user, profileData, publishEvent, queryClient, onNext]);
|
||||
}, [user, profileData, extraFields, publishEvent, queryClient, onNext]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
|
||||
@@ -715,6 +730,8 @@ function ProfileStep({
|
||||
}
|
||||
onPickImage={handlePickImage}
|
||||
showNip05={false}
|
||||
extraFields={extraFields}
|
||||
onExtraFieldsChange={setExtraFields}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -724,21 +741,31 @@ function ProfileStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handlePublishProfile}
|
||||
className="w-full rounded-full h-11 gap-1.5"
|
||||
disabled={isPublishing || isUploading || isSaving}
|
||||
>
|
||||
{isPublishing || isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue <ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onNext}
|
||||
className="flex-1 rounded-full h-11"
|
||||
disabled={isPublishing || isSaving}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePublishProfile}
|
||||
className="flex-1 rounded-full h-11 gap-1.5"
|
||||
disabled={isPublishing || isUploading || isSaving}
|
||||
>
|
||||
{isPublishing || isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Continue <ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -758,10 +785,8 @@ function ThemeStep({
|
||||
isFirst?: boolean;
|
||||
isSaving?: boolean;
|
||||
}) {
|
||||
const { theme, customTheme, themes } = useTheme();
|
||||
const resolved = resolveTheme(theme);
|
||||
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
|
||||
const bgUrl = activeConfig?.background?.url;
|
||||
const { customTheme } = useTheme();
|
||||
const bgUrl = customTheme?.background?.url;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -917,27 +942,32 @@ function FollowsStep({
|
||||
.filter(([n]) => n === "p")
|
||||
.map(([, pk]) => pk);
|
||||
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [3],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
// Fetch current follow list
|
||||
const followEvents: NostrEvent[] = await nostr
|
||||
.query([{ kinds: [3], authors: [user.pubkey], limit: 1 }], {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
.catch((): NostrEvent[] => []);
|
||||
|
||||
// 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 prev =
|
||||
followEvents.length > 0
|
||||
? followEvents.reduce((latest, current) =>
|
||||
current.created_at > latest.created_at ? current : latest,
|
||||
)
|
||||
: null;
|
||||
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = packPubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ["p", pk]);
|
||||
const existingFollows = prev
|
||||
? prev.tags
|
||||
.filter(([name]) => name === "p")
|
||||
.map(([, pk]) => pk)
|
||||
: [];
|
||||
|
||||
const allFollows = [...new Set([...existingFollows, ...packPubkeys])];
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: prev?.content ?? "",
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
tags: allFollows.map((pk) => ["p", pk]),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DittoLogo } from '@/components/DittoLogo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useTrendingTags } from '@/hooks/useTrending';
|
||||
import { themePresets, coreToTokens, type CoreThemeColors } from '@/themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -92,6 +93,7 @@ function ThemeSwatch({
|
||||
export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
const { config } = useAppContext();
|
||||
const { theme, customTheme, applyCustomTheme, setTheme } = useTheme();
|
||||
const { data: trendingData } = useTrendingTags();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
@@ -114,6 +116,8 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
return null;
|
||||
}, [theme, customTheme]);
|
||||
|
||||
const trendingTags = trendingData?.tags?.slice(0, 12) ?? [];
|
||||
|
||||
const updateScrollButtons = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -241,6 +245,31 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Trending Hashtags ── */}
|
||||
{trendingTags.length > 0 && (
|
||||
<div className="px-4 pb-4 landing-hero-fade" style={{ animationDelay: '320ms' }}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2.5">
|
||||
Trending now
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{trendingTags.map(({ tag, accounts }) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${tag}`}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/60 hover:bg-secondary text-xs font-medium text-secondary-foreground transition-colors"
|
||||
>
|
||||
<span className="text-primary">#</span>{tag}
|
||||
{accounts > 1 && (
|
||||
<span className="text-muted-foreground text-[10px] ml-0.5">
|
||||
{accounts}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Divider into feed ── */}
|
||||
<div className="border-b border-border" />
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function LeftSidebar() {
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const getDisplayName = (account: Account) => account.metadata.display_name || account.metadata.name || genUserName(account.pubkey);
|
||||
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setAccountPopoverOpen(false);
|
||||
@@ -151,7 +151,7 @@ export function LeftSidebar() {
|
||||
<Avatar shape={currentUserAvatarShape} className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LightningInvoiceCardProps {
|
||||
invoice: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Parse the sats amount from a BOLT11 invoice's human-readable part. */
|
||||
function parseBolt11Amount(bolt11: string): number | null {
|
||||
const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/);
|
||||
if (!match) return null;
|
||||
const value = parseInt(match[1], 10);
|
||||
if (isNaN(value)) return null;
|
||||
const multiplier = match[2];
|
||||
switch (multiplier) {
|
||||
case 'm': return value * 100_000; // milli-BTC → sats
|
||||
case 'u': return value * 100; // micro-BTC → sats
|
||||
case 'n': return value / 10; // nano-BTC → sats
|
||||
case 'p': return value / 10_000; // pico-BTC → sats
|
||||
default: return value * 100_000_000; // BTC → sats
|
||||
}
|
||||
}
|
||||
|
||||
/** Format sats with thousands separator. */
|
||||
function formatSats(sats: number): string {
|
||||
if (sats < 1) return '<1';
|
||||
const rounded = Math.round(sats);
|
||||
return rounded.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline card for rendering a BOLT11 lightning invoice found in note content.
|
||||
* Horizontal layout with theme-aware QR that expands on tap.
|
||||
* Amount text scales to fit via container query units.
|
||||
*/
|
||||
export function LightningInvoiceCard({ invoice, className }: LightningInvoiceCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [qrExpanded, setQrExpanded] = useState(false);
|
||||
|
||||
const amount = parseBolt11Amount(invoice);
|
||||
|
||||
// Generate theme-aware QR code
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const { dark, light } = getThemedQRColors();
|
||||
QRCode.toDataURL(invoice.toUpperCase(), {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
}).then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [invoice]);
|
||||
|
||||
const handleCopy = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice);
|
||||
setCopied(true);
|
||||
toast({ title: 'Copied', description: 'Lightning invoice copied to clipboard' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to copy', variant: 'destructive' });
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const t = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [copied]);
|
||||
|
||||
const handleOpenWallet = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await openUrl(`lightning:${invoice}`);
|
||||
}, [invoice]);
|
||||
|
||||
const handlePayWebLN = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const webln = (globalThis as { webln?: { enable?: () => Promise<void>; sendPayment?: (invoice: string) => Promise<unknown> } }).webln;
|
||||
if (!webln?.sendPayment) return;
|
||||
try {
|
||||
setPaying(true);
|
||||
if (webln.enable) await webln.enable();
|
||||
await webln.sendPayment(invoice);
|
||||
toast({ title: 'Payment sent' });
|
||||
} catch {
|
||||
toast({ title: 'Payment failed', variant: 'destructive' });
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}, [invoice, toast]);
|
||||
|
||||
const toggleQr = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setQrExpanded((v) => !v);
|
||||
}, []);
|
||||
|
||||
const hasWebLN = typeof globalThis !== 'undefined' && !!(globalThis as { webln?: unknown }).webln;
|
||||
|
||||
const qrImage = qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-square rounded-xl bg-muted animate-pulse" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'isolate my-2.5 relative rounded-2xl border border-border overflow-hidden @container',
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Subtle accent glow behind QR area */}
|
||||
<div className="absolute -z-10 top-0 left-0 w-44 h-44 bg-primary/[0.06] rounded-full blur-2xl" />
|
||||
|
||||
{/* Expanded QR -- square container that replaces the normal layout */}
|
||||
{qrExpanded ? (
|
||||
<button
|
||||
onClick={toggleQr}
|
||||
className="w-full aspect-square cursor-pointer p-5"
|
||||
>
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Lightning Invoice QR"
|
||||
className="w-full h-full rounded-xl"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-xl bg-muted animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
{/* QR code -- tappable thumbnail */}
|
||||
<button onClick={toggleQr} className="shrink-0 p-3 cursor-pointer">
|
||||
<div className="size-28 sm:size-40">{qrImage}</div>
|
||||
</button>
|
||||
|
||||
{/* Info column */}
|
||||
<div className="flex flex-col justify-between py-3.5 pr-3.5 min-w-0 flex-1 gap-2">
|
||||
{/* Label + amount */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground font-medium whitespace-nowrap" style={{ fontSize: 'clamp(0.8rem, 3.5cqw, 1.05rem)' }}>
|
||||
<span className="flex items-center justify-center size-5 sm:size-6 rounded-full bg-primary/15 shrink-0">
|
||||
<Zap className="size-3 sm:size-3.5 text-primary fill-primary" />
|
||||
</span>
|
||||
Lightning Invoice
|
||||
</div>
|
||||
{amount !== null && (
|
||||
<div className="font-bold tracking-tight leading-none mt-1 whitespace-nowrap" style={{ fontSize: 'clamp(1.5rem, 8cqw, 2.5rem)' }}>
|
||||
{formatSats(amount)}
|
||||
<span className="font-normal text-muted-foreground ml-1" style={{ fontSize: 'clamp(0.75rem, 3.5cqw, 1.125rem)' }}>sats</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice string with copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 group max-w-full"
|
||||
>
|
||||
<span className="truncate text-xs font-mono text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{invoice}
|
||||
</span>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary shrink-0" />
|
||||
: <Copy className="size-3.5 text-muted-foreground group-hover:text-foreground shrink-0 transition-colors" />}
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasWebLN && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handlePayWebLN}
|
||||
disabled={paying}
|
||||
className="gap-1.5 h-9 rounded-xl"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
{paying ? 'Paying...' : 'Pay'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleOpenWallet} className="gap-1.5 h-9 rounded-xl">
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open in Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { RightSidebar } from '@/components/RightSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
import { MobileDrawer } from '@/components/MobileDrawer';
|
||||
import { MobileBottomNav } from '@/components/MobileBottomNav';
|
||||
@@ -41,8 +42,61 @@ function PageSkeleton() {
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
{/* Right sidebar placeholder — preserves layout width */}
|
||||
<div className="w-[300px] shrink-0 hidden xl:block" />
|
||||
{/* Right sidebar skeleton — mirrors RightSidebar's container + widget card styling */}
|
||||
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
|
||||
{/* Trends widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-4 w-14" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-[28px] w-[50px] rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Hot Posts widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* New Accounts widget skeleton */}
|
||||
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<Skeleton className="h-6 w-28 mb-3" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -104,8 +158,7 @@ function MainLayoutInner() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
|
||||
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
|
||||
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -118,7 +171,7 @@ function MainLayoutInner() {
|
||||
{showFAB && (
|
||||
<div
|
||||
className="fixed bottom-fab right-6 z-30 pointer-events-none transition-transform duration-300 ease-in-out sidebar:hidden"
|
||||
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))` } : undefined}
|
||||
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)))` } : undefined}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} />
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useNip05Verify } from '@/hooks/useNip05Verify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePortalDropdown } from '@/hooks/usePortalDropdown';
|
||||
|
||||
interface MentionAutocompleteProps {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
@@ -90,14 +89,6 @@ export function MentionAutocomplete({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||
const { computePosition, renderPortal } = usePortalDropdown({
|
||||
textareaRef,
|
||||
isOpen,
|
||||
onClose: handleClose,
|
||||
dropdownHeight: 240, // must match max-h-[240px] below
|
||||
});
|
||||
|
||||
const { data: profiles, followedPubkeys } = useSearchProfiles(
|
||||
isOpen ? mentionQuery : '',
|
||||
);
|
||||
@@ -149,11 +140,15 @@ export function MentionAutocomplete({
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(0);
|
||||
|
||||
// Position the dropdown using fixed viewport coordinates so it isn't
|
||||
// clipped by ancestor overflow containers (e.g. the compose modal).
|
||||
// Position the dropdown below the @ character, relative to the textarea's
|
||||
// offsetParent (the `relative` wrapper div) so it stays inside the modal.
|
||||
const coords = getCaretCoordinates(textarea, atPos);
|
||||
setDropdownPos(computePosition(coords));
|
||||
}, [textareaRef, computePosition]);
|
||||
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
|
||||
setDropdownPos({
|
||||
top: coords.top + lineHeight + 4,
|
||||
left: Math.max(0, Math.min(coords.left, textarea.clientWidth - 280)),
|
||||
});
|
||||
}, [textareaRef]);
|
||||
|
||||
// Listen for input/cursor changes on the textarea element.
|
||||
// Re-attaches whenever the underlying DOM element changes (e.g. after
|
||||
@@ -259,10 +254,10 @@ export function MentionAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
const dropdown = (
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
className="absolute z-[100] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
|
||||
@@ -278,10 +273,6 @@ export function MentionAutocomplete({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to document.body so the dropdown escapes any ancestor overflow
|
||||
// clipping and CSS transform containing blocks (e.g. Radix Dialog).
|
||||
return renderPortal(dropdown, document.body);
|
||||
}
|
||||
|
||||
function MentionItem({
|
||||
|
||||
@@ -40,8 +40,8 @@ export function MobileBottomNav() {
|
||||
setSearchOpen((v) => !v);
|
||||
}, []);
|
||||
|
||||
// Hide the nav when search sheet is open so it doesn't compete for space
|
||||
const isHidden = hidden || searchOpen;
|
||||
// Keep the nav visible while search is open regardless of scroll
|
||||
const isHidden = hidden && !searchOpen;
|
||||
|
||||
const displayName = metadata?.name || metadata?.display_name;
|
||||
const isOnProfile = user && location.pathname === profileUrl;
|
||||
@@ -137,8 +137,8 @@ export function MobileBottomNav() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Safe area fill — matches the arc's semi-transparent background */}
|
||||
<div className="safe-area-bottom bg-background/85" />
|
||||
{/* Safe area spacer — fully opaque so any subpixel gap is invisible */}
|
||||
<div className="safe-area-bottom bg-background" />
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -140,7 +140,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
<button
|
||||
onClick={() => setAccountExpanded((v) => !v)}
|
||||
className="flex items-center gap-3 px-3 hover:bg-secondary/60 transition-colors w-full text-left"
|
||||
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
|
||||
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
||||
>
|
||||
<Avatar shape={currentUserAvatarShape} className="size-7 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
@@ -336,7 +336,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
{/* Login prompt */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 border-b border-border"
|
||||
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
|
||||
style={{ minHeight: `calc(3rem + env(safe-area-inset-top, 0px))`, paddingTop: `env(safe-area-inset-top, 0px)` }}
|
||||
>
|
||||
<LoginArea className="w-full flex" />
|
||||
</div>
|
||||
|
||||
@@ -101,28 +101,6 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
const wikipediaIndex = hasWikipedia ? nextMobileIdx++ : -1;
|
||||
const archiveIndex = hasArchive ? nextMobileIdx++ : -1;
|
||||
|
||||
// Lock body scroll while the search sheet is open.
|
||||
// overflow:hidden alone is unreliable on mobile Safari, so we also
|
||||
// block touchmove on the document (except inside the results scroller).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
// Allow scrolling inside the results list
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-mobile-search-results]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
document.removeEventListener('touchmove', preventScroll);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -246,8 +224,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
|
||||
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
|
||||
{/* Bottom sheet — sits above the bottom nav bar */}
|
||||
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 bottom-mobile-nav">
|
||||
|
||||
{/* Results list — reversed so closest to input = most relevant */}
|
||||
{hasResults && (
|
||||
@@ -315,7 +293,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="flex items-center px-6 py-3 safe-area-bottom">
|
||||
<div className="flex items-center px-6 py-3">
|
||||
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
|
||||
{isFetching ? (
|
||||
<svg
|
||||
@@ -343,12 +321,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
{query.length > 0 && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X strokeWidth={4} className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,12 @@ export function MobileTopBar({ onAvatarClick, hasSubHeader }: MobileTopBarProps)
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 sidebar:hidden safe-area-top transition-transform duration-300 ease-in-out"
|
||||
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))))' } : undefined}
|
||||
style={navHidden ? { transform: 'translateY(calc(-100% - 20px - env(safe-area-inset-top, 0px)))' } : undefined}
|
||||
>
|
||||
{/* Safe-area fill — only covers the padding zone above the content with a single layer of bg. */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bg-background/85"
|
||||
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
|
||||
style={{ height: 'env(safe-area-inset-top, 0px)' }}
|
||||
/>
|
||||
{/* Relative wrapper so ArcBackground only covers the content area, not the safe-area padding above it. */}
|
||||
<div className="relative">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NoteContent } from './NoteContent';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
describe('NoteContent', () => {
|
||||
it('linkifies URLs in kind 1 events', async () => {
|
||||
it('linkifies URLs in kind 1 events', () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -22,13 +22,13 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'https://example.com' });
|
||||
const link = screen.getByRole('link', { name: 'https://example.com' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('linkifies URLs in kind 1111 events (comments)', async () => {
|
||||
it('linkifies URLs in kind 1111 events (comments)', () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-comment-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -49,13 +49,13 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
|
||||
const link = screen.getByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('handles text without URLs correctly', async () => {
|
||||
it('handles text without URLs correctly', () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -72,11 +72,11 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('This is just plain text without any links.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hashtags as links', async () => {
|
||||
it('renders hashtags as links', () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
@@ -93,7 +93,7 @@ describe('NoteContent', () => {
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const nostrHashtag = await screen.findByRole('link', { name: '#nostr' });
|
||||
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
|
||||
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
|
||||
|
||||
expect(nostrHashtag).toBeInTheDocument();
|
||||
@@ -102,7 +102,7 @@ describe('NoteContent', () => {
|
||||
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
|
||||
});
|
||||
|
||||
it('generates deterministic names for users without metadata and styles them differently', async () => {
|
||||
it('generates deterministic names for users without metadata and styles them differently', () => {
|
||||
// Use a valid npub for testing
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
@@ -121,7 +121,7 @@ describe('NoteContent', () => {
|
||||
);
|
||||
|
||||
// The mention should be rendered with a deterministic name
|
||||
const mention = await screen.findByRole('link');
|
||||
const mention = screen.getByRole('link');
|
||||
expect(mention).toBeInTheDocument();
|
||||
|
||||
// Should have muted styling for generated names (muted-foreground instead of primary)
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
@@ -177,8 +176,7 @@ type ContentToken =
|
||||
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
|
||||
| { type: 'nostr-link'; id: string; raw: string }
|
||||
| { type: 'hashtag'; tag: string; raw: string }
|
||||
| { type: 'relay-link'; url: string }
|
||||
| { type: 'lightning-invoice'; invoice: string };
|
||||
| { type: 'relay-link'; url: string };
|
||||
|
||||
/**
|
||||
* Regex segment matching a single visual emoji unit, including:
|
||||
@@ -236,10 +234,9 @@ export function NoteContent({
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
|
||||
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
|
||||
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
|
||||
const regex = /((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/gu;
|
||||
|
||||
const result: ContentToken[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -247,11 +244,9 @@ export function NoteContent({
|
||||
let hadMatches = false;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
let [fullMatch] = match;
|
||||
const bolt11 = match[1];
|
||||
let url = match[2];
|
||||
const hashtag = match[7];
|
||||
const { 3: nostrPrefix, 4: nostrData, 5: barePrefix, 6: bareData } = match;
|
||||
let [fullMatch, url] = match;
|
||||
const hashtag = match[6];
|
||||
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
|
||||
const index = match.index;
|
||||
hadMatches = true;
|
||||
|
||||
@@ -260,9 +255,7 @@ export function NoteContent({
|
||||
result.push({ type: 'text', value: text.substring(lastIndex, index) });
|
||||
}
|
||||
|
||||
if (bolt11) {
|
||||
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
|
||||
} else if (url) {
|
||||
if (url) {
|
||||
// Strip common trailing punctuation that's likely not part of the URL
|
||||
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
|
||||
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
|
||||
@@ -416,7 +409,7 @@ export function NoteContent({
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const token = result[i];
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
|
||||
|| (token.type === 'naddr-embed' && !token.url);
|
||||
|
||||
if (isBlock) {
|
||||
// Strip all trailing whitespace from the preceding text token.
|
||||
@@ -675,11 +668,6 @@ export function NoteContent({
|
||||
{token.url}
|
||||
</Link>
|
||||
);
|
||||
case 'lightning-invoice':
|
||||
if (disableEmbeds) {
|
||||
return <span key={i} className="text-primary break-all">{token.invoice}</span>;
|
||||
}
|
||||
return <LightningInvoiceCard key={i} invoice={token.invoice} />;
|
||||
}
|
||||
})}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLinkPreview } from "@/hooks/useLinkPreview";
|
||||
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
|
||||
import { sanitizeUrl } from "@/lib/sanitizeUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NsiteCardProps {
|
||||
@@ -25,7 +24,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
const title = event.tags.find(([n]) => n === "title")?.[1];
|
||||
const description = event.tags.find(([n]) => n === "description")?.[1];
|
||||
const dTag = event.tags.find(([n]) => n === "d")?.[1];
|
||||
const sourceUrl = sanitizeUrl(event.tags.find(([n]) => n === "source")?.[1]);
|
||||
const sourceUrl = event.tags.find(([n]) => n === "source")?.[1];
|
||||
const pathTags = event.tags.filter(([n]) => n === "path");
|
||||
const serverTags = event.tags.filter(([n]) => n === "server");
|
||||
|
||||
|
||||
@@ -2,18 +2,14 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Package, X } from 'lucide-react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SandboxFrame } from '@/components/SandboxFrame';
|
||||
import { useCenterColumn } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
|
||||
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
|
||||
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
import { getPreviewInjectedScript } from '@/lib/previewInjectedScript';
|
||||
import { getMimeType } from '@/lib/sandbox';
|
||||
import type { FileResponse, InjectedScript } from '@/lib/sandbox';
|
||||
|
||||
interface Rect { left: number; top: number; width: number; height: number }
|
||||
|
||||
@@ -39,6 +35,38 @@ function useElementRect(el: HTMLElement | null): Rect | null {
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** The wildcard preview domain (iframe.diy service worker sandbox). */
|
||||
const PREVIEW_DOMAIN = 'iframe.diy';
|
||||
|
||||
interface JSONRPCFetchRequest {
|
||||
jsonrpc: '2.0';
|
||||
method: 'fetch';
|
||||
params: {
|
||||
request: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface JSONRPCResponse {
|
||||
jsonrpc: '2.0';
|
||||
result?: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the path→sha256 manifest from a nsite event's `path` tags.
|
||||
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
|
||||
@@ -69,106 +97,60 @@ function resolveServers(event: NostrEvent, appServers: string[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level preferred server. Once a Blossom server successfully serves
|
||||
* a blob, it is promoted here so subsequent requests try it first — avoiding
|
||||
* the round-trip penalty of 404s on servers that don't have the content.
|
||||
*/
|
||||
let preferredServer: string | null = null;
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying Blossom servers.
|
||||
*
|
||||
* If a server previously succeeded (the "preferred" server), it is tried
|
||||
* first. On success the preferred server is reinforced; on failure we fall
|
||||
* through to the remaining servers in order. Whichever server ultimately
|
||||
* succeeds is promoted to preferred for the next call.
|
||||
* Fetch a blob from the given sha256 by trying each Blossom server in order.
|
||||
* Returns a Response from the first server that responds successfully, or
|
||||
* throws if all servers fail.
|
||||
*/
|
||||
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
|
||||
/** Try a single server. Returns the Response on success, or null. */
|
||||
async function tryServer(server: string): Promise<Response | null> {
|
||||
for (const server of servers) {
|
||||
const base = server.replace(/\/+$/, '');
|
||||
const url = `${base}/${sha256}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
preferredServer = server;
|
||||
return res;
|
||||
}
|
||||
if (res.ok) return res;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try the preferred server first if it's in the list.
|
||||
if (preferredServer && servers.includes(preferredServer)) {
|
||||
const res = await tryServer(preferredServer);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
// Fall through to the full list, skipping the preferred (already tried).
|
||||
for (const server of servers) {
|
||||
if (server === preferredServer) continue;
|
||||
const res = await tryServer(server);
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
|
||||
}
|
||||
|
||||
/** Max concurrent Blossom fetches during pre-fetch. */
|
||||
const PREFETCH_CONCURRENCY = 12;
|
||||
|
||||
/**
|
||||
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
|
||||
*
|
||||
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
|
||||
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
|
||||
* If each response requires a network round-trip to Blossom, the 6-at-a-time
|
||||
* serialisation makes loading 200+ files extremely slow. By downloading
|
||||
* every blob *before* the WebView starts loading, each bridge round-trip
|
||||
* drops from seconds (network) to ~1-5ms (memory).
|
||||
*
|
||||
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
|
||||
* handle many concurrent requests without any thread pool bottleneck.
|
||||
*
|
||||
* Uses bounded concurrency to saturate the network without overwhelming it.
|
||||
* Guess a MIME type from a file path extension.
|
||||
* Falls back to 'application/octet-stream' for unknown extensions.
|
||||
*/
|
||||
async function prefetchAllBlobs(
|
||||
manifest: Map<string, string>,
|
||||
servers: string[],
|
||||
cache: Map<string, Uint8Array>,
|
||||
): Promise<void> {
|
||||
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
|
||||
const uniqueHashes = [...new Set(manifest.values())];
|
||||
// Skip hashes already in the cache (e.g. from a previous open).
|
||||
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
let cursor = 0;
|
||||
const total = toFetch.length;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < total) {
|
||||
const idx = cursor++;
|
||||
const sha256 = toFetch[idx];
|
||||
try {
|
||||
const res = await fetchFromBlossom(sha256, servers);
|
||||
const buffer = await res.arrayBuffer();
|
||||
cache.set(sha256, new Uint8Array(buffer));
|
||||
} catch {
|
||||
// Non-fatal — resolveFile will fetch on demand for cache misses.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
function guessMimeType(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
||||
const map: Record<string, string> = {
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
css: 'text/css',
|
||||
js: 'application/javascript',
|
||||
mjs: 'application/javascript',
|
||||
json: 'application/json',
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
otf: 'font/otf',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
wasm: 'application/wasm',
|
||||
xml: 'application/xml',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
};
|
||||
return map[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
interface NsitePreviewDialogProps {
|
||||
@@ -184,37 +166,40 @@ interface NsitePreviewDialogProps {
|
||||
|
||||
/**
|
||||
* An in-app preview panel that covers the center column and loads an nsite in
|
||||
* a sandboxed iframe.
|
||||
* an iframe.diy sandbox.
|
||||
*
|
||||
* Files are served directly from Blossom servers using the manifest data
|
||||
* embedded in the nsite event's `path` tags. Each path tag maps a file path
|
||||
* to its sha256 hash, which is used to construct a Blossom content-addressed URL.
|
||||
* Instead of proxying requests through an nsite gateway, this component serves
|
||||
* files directly from Blossom servers using the manifest data embedded in the
|
||||
* nsite event's `path` tags. Each path tag maps a file path to its sha256 hash,
|
||||
* which is used to construct a Blossom content-addressed URL.
|
||||
*
|
||||
* The panel is portaled into the center column DOM element (via CenterColumnContext)
|
||||
* and uses `position: fixed` to fill the viewport column area.
|
||||
*
|
||||
* iframe.diy provides a service-worker based sandbox. The handshake is:
|
||||
* 1. iframe.diy sends a `ready` JSON-RPC notification when its SW is installed
|
||||
* 2. Parent responds with `init` notification
|
||||
* 3. iframe.diy then forwards `fetch` JSON-RPC requests for all navigations
|
||||
* 4. Parent serves files from Blossom and injects a preview script into HTML
|
||||
*/
|
||||
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const centerColumn = useCenterColumn();
|
||||
const columnRect = useElementRect(open ? centerColumn : null);
|
||||
const { config } = useAppContext();
|
||||
|
||||
// Use the NIP-5A canonical subdomain as the stable identifier, then derive
|
||||
// a private HMAC-SHA256 subdomain so the raw identifier is never exposed as
|
||||
// a sandbox origin (preventing cross-app localStorage/IndexedDB collisions).
|
||||
// an iframe.diy origin (preventing cross-app localStorage/IndexedDB collisions).
|
||||
const nsiteSubdomain = getNsiteSubdomain(event);
|
||||
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
|
||||
const iframeOrigin = useMemo(() => `https://${previewSubdomain}.${PREVIEW_DOMAIN}`, [previewSubdomain]);
|
||||
const iframeSrc = `${iframeOrigin}/`;
|
||||
|
||||
// Build the manifest and server list from the event (memoised per event identity)
|
||||
const manifest = useRef<Map<string, string>>(new Map());
|
||||
const servers = useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* In-memory blob cache: sha256 → raw bytes.
|
||||
* On Android, populated by a blocking pre-fetch in `onReady` so every
|
||||
* `resolveFile` call is an instant cache hit with no network wait.
|
||||
*/
|
||||
const blobCache = useRef<Map<string, Uint8Array>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
manifest.current = buildManifest(event);
|
||||
const appServers = getEffectiveBlossomServers(
|
||||
@@ -224,70 +209,186 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
|
||||
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
|
||||
|
||||
/** Injected scripts: just the path normalisation snippet for SPA support. */
|
||||
const injectedScripts = useMemo<InjectedScript[]>(() => [{
|
||||
path: '__injected__/preview.js',
|
||||
content: getPreviewInjectedScript(),
|
||||
}], []);
|
||||
/** Send a JSON-RPC response back to the iframe. */
|
||||
const sendResponse = useCallback((message: JSONRPCResponse) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
|
||||
}, [iframeOrigin]);
|
||||
|
||||
/**
|
||||
* Called by SandboxFrame before the native WebView is created.
|
||||
*
|
||||
* On Android: blocks until all blobs are pre-fetched. Android's WebView
|
||||
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
|
||||
* response requires a network fetch the whole thing is painfully slow.
|
||||
* The native ProgressBar spinner (render thread) stays visible and
|
||||
* animating during the download. Once the WebView starts, every
|
||||
* resolveFile call is an instant cache hit.
|
||||
*
|
||||
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
|
||||
* requests without a thread pool bottleneck.
|
||||
*
|
||||
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
|
||||
*/
|
||||
const onReady = useCallback(async () => {
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
|
||||
/** Virtual path where the injected preview script is served. */
|
||||
const INJECTED_SCRIPT_PATH = '/__injected__/preview.js';
|
||||
|
||||
/** Inject a <script> tag into an HTML string so the preview script runs first. */
|
||||
const injectScript = useCallback((html: string): string => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const tag = doc.createElement('script');
|
||||
tag.src = INJECTED_SCRIPT_PATH;
|
||||
doc.head.insertBefore(tag, doc.head.firstChild);
|
||||
return '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
|
||||
}, []);
|
||||
|
||||
/** Resolve a pathname to file content from the Blossom manifest. */
|
||||
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
// If not found, fall back to /index.html (SPA client-side routing).
|
||||
let sha256 = manifest.current.get(pathname);
|
||||
let servingPath = pathname;
|
||||
/** Encode a string as base64. */
|
||||
const encodeBase64 = (str: string): string => btoa(unescape(encodeURIComponent(str)));
|
||||
|
||||
if (!sha256) {
|
||||
sha256 = manifest.current.get('/index.html');
|
||||
servingPath = '/index.html';
|
||||
/** Encode raw bytes as base64. */
|
||||
const encodeBytesBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
if (!sha256) return null;
|
||||
/** Handle a fetch request from the iframe by serving files directly from Blossom. */
|
||||
const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => {
|
||||
const { params, id } = request;
|
||||
const { request: fetchRequest } = params;
|
||||
|
||||
// Serve from cache if available (pre-fetched on Android).
|
||||
const cached = blobCache.current.get(sha256);
|
||||
if (cached) {
|
||||
const contentType = getMimeType(servingPath);
|
||||
return { status: 200, contentType, body: cached };
|
||||
try {
|
||||
const requestedUrl = new URL(fetchRequest.url);
|
||||
|
||||
// Only serve requests for our iframe origin
|
||||
if (requestedUrl.origin !== iframeOrigin) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32003, message: 'Origin mismatch' },
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedPath = requestedUrl.pathname;
|
||||
|
||||
// Serve the injected preview script at its virtual path
|
||||
if (requestedPath === INJECTED_SCRIPT_PATH) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
body: encodeBase64(getPreviewInjectedScript()),
|
||||
},
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
// If not found, fall back to /index.html (SPA client-side routing).
|
||||
let sha256 = manifest.current.get(requestedPath);
|
||||
let servingPath = requestedPath;
|
||||
|
||||
if (!sha256) {
|
||||
sha256 = manifest.current.get('/index.html');
|
||||
servingPath = '/index.html';
|
||||
}
|
||||
|
||||
if (!sha256) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: btoa('Not Found'),
|
||||
},
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the blob from Blossom, trying each server in order
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
|
||||
// Read as ArrayBuffer → base64 so binary assets work correctly
|
||||
const buffer = await res.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
// the manifest is authoritative for the correct MIME type.
|
||||
const contentType = guessMimeType(servingPath);
|
||||
|
||||
// Inject preview script into HTML responses for console/navigation support
|
||||
let bodyBase64: string;
|
||||
if (contentType === 'text/html') {
|
||||
const html = new TextDecoder().decode(bytes);
|
||||
bodyBase64 = encodeBase64(injectScript(html));
|
||||
} else {
|
||||
bodyBase64 = encodeBytesBase64(bytes);
|
||||
}
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: responseHeaders,
|
||||
body: bodyBase64,
|
||||
},
|
||||
id,
|
||||
});
|
||||
} catch (err) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32002, message: String(err) },
|
||||
id,
|
||||
});
|
||||
}
|
||||
}, [iframeOrigin, sendResponse, injectScript]);
|
||||
|
||||
// Cache miss — fetch from Blossom (normal path on iOS/web).
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const body = new Uint8Array(buffer);
|
||||
/** Send a JSON-RPC notification to the iframe. */
|
||||
const sendNotification = useCallback((method: string, params?: Record<string, unknown>) => {
|
||||
iframeRef.current?.contentWindow?.postMessage({
|
||||
jsonrpc: '2.0' as const,
|
||||
method,
|
||||
params: params ?? {},
|
||||
}, iframeOrigin);
|
||||
}, [iframeOrigin]);
|
||||
|
||||
// Store in cache for future requests (e.g. SPA navigations).
|
||||
blobCache.current.set(sha256, body);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
// the manifest is authoritative for the correct MIME type.
|
||||
const contentType = getMimeType(servingPath);
|
||||
|
||||
return { status: 200, contentType, body };
|
||||
/** Handle navigation state updates from the iframe (no-op). */
|
||||
const handleNavigationState = useCallback((_params: {
|
||||
currentUrl: string;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
}) => {
|
||||
// intentionally empty
|
||||
}, []);
|
||||
|
||||
// Listen for messages from the iframe
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== iframeOrigin) return;
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') return;
|
||||
|
||||
// Handle iframe.diy handshake: respond to "ready" with "init"
|
||||
if (message.method === 'ready') {
|
||||
sendNotification('init', { version: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === 'fetch') {
|
||||
handleFetch(message as JSONRPCFetchRequest);
|
||||
} else if (message.method === 'updateNavigationState') {
|
||||
handleNavigationState(message.params);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [iframeOrigin, handleFetch, handleNavigationState, sendNotification]);
|
||||
|
||||
|
||||
|
||||
if (!open || !centerColumn || !columnRect) return null;
|
||||
|
||||
// If the user has scrolled down, columnRect.top is negative (the column top
|
||||
@@ -307,7 +408,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
}}
|
||||
>
|
||||
{/* Nav bar */}
|
||||
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
|
||||
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{appPicture ? (
|
||||
@@ -336,14 +437,12 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sandboxed iframe */}
|
||||
{/* iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background">
|
||||
<SandboxFrame
|
||||
<iframe
|
||||
key={`${previewSubdomain}-${open}`}
|
||||
id={previewSubdomain}
|
||||
resolveFile={resolveFile}
|
||||
onReady={onReady}
|
||||
injectedScripts={injectedScripts}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
className="w-full h-full border-0"
|
||||
title={`${appName} preview`}
|
||||
/>
|
||||
|
||||
@@ -206,11 +206,9 @@ export function ProfileCard({
|
||||
<Pencil className="size-3.5" /> {metadata.banner ? 'Change banner' : 'Add banner'}
|
||||
</span>
|
||||
</div>
|
||||
{metadata.banner && (
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 right-2 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -242,11 +240,9 @@ export function ProfileCard({
|
||||
>
|
||||
<Pencil className="size-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" />
|
||||
</div>
|
||||
{metadata.picture && (
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 right-0 size-7 rounded-full bg-background border border-border shadow-sm flex items-center justify-center transition-opacity">
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={6}>
|
||||
|
||||
@@ -25,7 +25,6 @@ import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { parseDimToAspectRatio } from '@/lib/mediaUtils';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
|
||||
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
|
||||
@@ -401,24 +400,24 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
}
|
||||
|
||||
// Media fields: render inline players/previews based on file extension
|
||||
const safeUrl = sanitizeUrl(field.value);
|
||||
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
|
||||
|
||||
if (safeUrl && isAudioUrl(safeUrl)) {
|
||||
if (isUrl && isAudioUrl(field.value)) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-sm mb-1.5">{field.label}</div>
|
||||
<MiniAudioPlayer src={safeUrl} />
|
||||
<MiniAudioPlayer src={field.value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (safeUrl && isImageUrl(safeUrl)) {
|
||||
if (isUrl && isImageUrl(field.value)) {
|
||||
return (
|
||||
<div>
|
||||
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
|
||||
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<a href={field.value} target="_blank" rel="noopener noreferrer" className="block">
|
||||
<img
|
||||
src={safeUrl}
|
||||
src={field.value}
|
||||
alt={field.label || 'Profile image'}
|
||||
className="w-full rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
@@ -428,12 +427,12 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (safeUrl && isVideoUrl(safeUrl)) {
|
||||
if (isUrl && isVideoUrl(field.value)) {
|
||||
return (
|
||||
<div>
|
||||
{field.label && <div className="font-semibold text-sm mb-1.5">{field.label}</div>}
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<VideoPlayer src={safeUrl} />
|
||||
<VideoPlayer src={field.value} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -443,15 +442,15 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{field.label}</div>
|
||||
{safeUrl ? (
|
||||
{isUrl ? (
|
||||
<a
|
||||
href={safeUrl}
|
||||
href={field.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline truncate mt-0.5"
|
||||
>
|
||||
<ExternalFavicon url={safeUrl} size={16} className="shrink-0" />
|
||||
<span className="truncate">{safeUrl.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
|
||||
<span className="truncate">{field.value.replace(/^https?:\/\//, '')}</span>
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground truncate">{field.value}</p>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Globe, Radio, Loader2, X, ArrowRight, ArrowLeft, Flame } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRequestToVanish } from '@/hooks/useRequestToVanish';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
@@ -17,38 +22,30 @@ interface RequestToVanishDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DELETION_ITEMS = [
|
||||
{ id: 'profile', label: 'Your profile and metadata' },
|
||||
{ id: 'posts', label: 'All posts, replies, and reactions' },
|
||||
{ id: 'messages', label: 'Direct messages' },
|
||||
{ id: 'settings', label: 'Follow lists and settings' },
|
||||
{ id: 'other', label: 'All other events submitted to the network' },
|
||||
] as const;
|
||||
type VanishMode = 'global' | 'targeted';
|
||||
type Step = 0 | 1 | 2;
|
||||
|
||||
type ItemId = (typeof DELETION_ITEMS)[number]['id'];
|
||||
const STEPS = ['Scope', 'Details', 'Confirm'] as const;
|
||||
const CONFIRMATION_PHRASE = 'VANISH';
|
||||
|
||||
export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDialogProps) {
|
||||
const { config } = useAppContext();
|
||||
const { mutateAsync: requestVanish, isPending } = useRequestToVanish();
|
||||
const { logout } = useLoginActions();
|
||||
|
||||
const [checked, setChecked] = useState<Set<ItemId>>(new Set());
|
||||
const [step, setStep] = useState<Step>(0);
|
||||
const [mode, setMode] = useState<VanishMode>('global');
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
const allChecked = DELETION_ITEMS.every((item) => checked.has(item.id));
|
||||
|
||||
const toggle = (id: ItemId) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const userRelays = config.relayMetadata.relays.map((r) => r.url);
|
||||
const isConfirmed = confirmText === CONFIRMATION_PHRASE;
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setChecked(new Set());
|
||||
setStep(0);
|
||||
setMode('global');
|
||||
setReason('');
|
||||
setConfirmText('');
|
||||
}, []);
|
||||
|
||||
// Reset when dialog closes.
|
||||
@@ -57,90 +54,411 @@ export function RequestToVanishDialog({ open, onOpenChange }: RequestToVanishDia
|
||||
}, [open, resetState]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!allChecked) return;
|
||||
if (!isConfirmed) return;
|
||||
|
||||
try {
|
||||
await requestVanish({ relayUrls: ['ALL_RELAYS'], content: '' });
|
||||
const relayUrls = mode === 'global' ? ['ALL_RELAYS'] : userRelays;
|
||||
|
||||
await requestVanish({ relayUrls, content: reason.trim() });
|
||||
|
||||
toast({
|
||||
title: 'Account deleted',
|
||||
description: 'Your deletion request has been broadcast. You have been logged out.',
|
||||
title: 'Request to vanish sent',
|
||||
description: mode === 'global'
|
||||
? 'Your request has been broadcast. Compliant relays will delete your data.'
|
||||
: `Your request was sent to ${userRelays.length} relay(s).`,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
await logout();
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Failed to delete account',
|
||||
description: 'Something went wrong. You can try again.',
|
||||
title: 'Failed to send request',
|
||||
description: 'Some relays may not have received the request. You can try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="max-w-[400px] rounded-2xl p-6 gap-0 border-destructive/40">
|
||||
{/* Title */}
|
||||
<div className="mb-4">
|
||||
<AlertDialogTitle className="text-base font-bold flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0" />
|
||||
Delete Account
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-sm text-muted-foreground mt-1">
|
||||
This will <span className="font-semibold text-destructive">permanently delete your data</span>. Check each box to confirm you understand what will be removed:
|
||||
</AlertDialogDescription>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[440px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[90dvh] [&>button]:hidden">
|
||||
{/* ── Header ── */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Gradient backdrop */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-destructive/10 via-destructive/5 to-transparent" />
|
||||
|
||||
<div className="relative px-5 pt-5 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-destructive/15 ring-1 ring-destructive/20 shrink-0">
|
||||
<Flame className="size-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-base font-bold">Request to Vanish</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
Permanently erase your data from relays
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1 -mt-0.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-1.5 mt-4">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-1.5 flex-1">
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full h-1 rounded-full overflow-hidden bg-muted/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500 ease-out',
|
||||
i <= step ? 'bg-destructive w-full' : 'w-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium transition-colors',
|
||||
i <= step ? 'text-destructive' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkbox list */}
|
||||
<div className="space-y-3 mb-5">
|
||||
{DELETION_ITEMS.map((item) => (
|
||||
<label
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 cursor-pointer select-none"
|
||||
<Separator />
|
||||
|
||||
{/* ── Step Content ── */}
|
||||
<div className="overflow-y-auto min-h-0 flex-1">
|
||||
{step === 0 && <StepScope mode={mode} setMode={setMode} userRelays={userRelays} />}
|
||||
{step === 1 && <StepDetails reason={reason} setReason={setReason} mode={mode} userRelays={userRelays} />}
|
||||
{step === 2 && (
|
||||
<StepConfirm
|
||||
confirmText={confirmText}
|
||||
setConfirmText={setConfirmText}
|
||||
mode={mode}
|
||||
relayCount={userRelays.length}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5">
|
||||
{step > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setStep((s) => (s - 1) as Step)}
|
||||
disabled={isPending}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked.has(item.id)}
|
||||
onCheckedChange={() => toggle(item.id)}
|
||||
className="border-destructive/60 data-[state=checked]:bg-destructive data-[state=checked]:border-destructive"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-5">
|
||||
This action is <span className="font-semibold text-destructive">irreversible</span>.
|
||||
Your account cannot be recovered after deletion. You will be logged out immediately.
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-1.5 bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
|
||||
onClick={handleSubmit}
|
||||
disabled={!allChecked || isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete Account'
|
||||
)}
|
||||
</Button>
|
||||
{step < 2 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setStep((s) => (s + 1) as Step)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isConfirmed || isPending}
|
||||
className="gap-1.5 bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flame className="size-3.5" />
|
||||
Vanish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 0: Scope ───────────────────────── */
|
||||
|
||||
function StepScope({
|
||||
mode,
|
||||
setMode,
|
||||
userRelays,
|
||||
}: {
|
||||
mode: VanishMode;
|
||||
setMode: (m: VanishMode) => void;
|
||||
userRelays: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Choose scope</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Select which relays should delete your data. This determines the reach of your vanish request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ScopeCard
|
||||
selected={mode === 'global'}
|
||||
onClick={() => setMode('global')}
|
||||
icon={<Globe className="size-5" />}
|
||||
title="All relays"
|
||||
description="Request every relay on the network to delete your data. The event is broadcast as widely as possible."
|
||||
badge="Recommended"
|
||||
/>
|
||||
<ScopeCard
|
||||
selected={mode === 'targeted'}
|
||||
onClick={() => setMode('targeted')}
|
||||
icon={<Radio className="size-5" />}
|
||||
title={`My relays only (${userRelays.length})`}
|
||||
description="Request only your currently configured relays to delete your data."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Relay list preview for targeted mode */}
|
||||
{mode === 'targeted' && userRelays.length > 0 && (
|
||||
<div className="rounded-lg bg-muted/40 border border-border/50 px-3 py-2.5 space-y-1.5 animate-in fade-in-0 slide-in-from-top-1 duration-200">
|
||||
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Target relays</p>
|
||||
<ul className="space-y-0.5">
|
||||
{userRelays.map((url) => (
|
||||
<li key={url} className="text-xs font-mono text-muted-foreground truncate">{url}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeCard({
|
||||
selected,
|
||||
onClick,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border-2 p-3.5 transition-all duration-200',
|
||||
'hover:bg-secondary/30',
|
||||
selected
|
||||
? 'border-destructive/60 bg-destructive/[0.03] shadow-sm shadow-destructive/5'
|
||||
: 'border-border/60 bg-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'flex size-9 items-center justify-center rounded-lg shrink-0 transition-colors',
|
||||
selected ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground',
|
||||
)}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-medium bg-destructive/10 text-destructive rounded-full px-2 py-0.5">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
{/* Selection indicator */}
|
||||
<div className={cn(
|
||||
'size-4 rounded-full border-2 shrink-0 mt-0.5 transition-all duration-200 flex items-center justify-center',
|
||||
selected ? 'border-destructive bg-destructive' : 'border-muted-foreground/30',
|
||||
)}>
|
||||
{selected && <div className="size-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 1: Details ───────────────────────── */
|
||||
|
||||
function StepDetails({
|
||||
reason,
|
||||
setReason,
|
||||
mode,
|
||||
userRelays,
|
||||
}: {
|
||||
reason: string;
|
||||
setReason: (r: string) => void;
|
||||
mode: VanishMode;
|
||||
userRelays: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Summary of what will happen */}
|
||||
<div className="rounded-xl bg-destructive/[0.04] border border-destructive/15 p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-destructive flex items-center gap-2">
|
||||
<Flame className="size-4" />
|
||||
What will be deleted
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
'Your profile (kind 0) and metadata',
|
||||
'All posts, replies, and reactions',
|
||||
'Direct messages and gift wraps',
|
||||
'Contact lists, relay lists, and settings',
|
||||
'All other events published by your key',
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="text-destructive/60 mt-0.5 shrink-0">—</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[11px] text-destructive/70 pt-1 border-t border-destructive/10">
|
||||
{mode === 'global'
|
||||
? 'This request will be sent to all relays on the network.'
|
||||
: `This request will be sent to ${userRelays.length} relay(s).`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vanish-reason" className="text-sm font-medium">
|
||||
Reason or legal notice
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Optionally include a message for the relay operator. This is included in the event's content field.
|
||||
</p>
|
||||
<Textarea
|
||||
id="vanish-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. GDPR Article 17 — Right to erasure"
|
||||
className="resize-none text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Step 2: Confirm ───────────────────────── */
|
||||
|
||||
function StepConfirm({
|
||||
confirmText,
|
||||
setConfirmText,
|
||||
mode,
|
||||
relayCount,
|
||||
}: {
|
||||
confirmText: string;
|
||||
setConfirmText: (t: string) => void;
|
||||
mode: VanishMode;
|
||||
relayCount: number;
|
||||
}) {
|
||||
const isMatch = confirmText === CONFIRMATION_PHRASE;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Final warning */}
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4 text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="size-12 rounded-full bg-destructive/15 flex items-center justify-center">
|
||||
<Flame className="size-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-destructive">This action is irreversible</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed max-w-[280px] mx-auto">
|
||||
Once sent, compliant relays will permanently delete your events.
|
||||
Deletion requests (kind 5) against this event have no effect.
|
||||
You will be logged out immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scope summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/40 px-3.5 py-2.5">
|
||||
{mode === 'global' ? (
|
||||
<Globe className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Radio className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{mode === 'global'
|
||||
? 'Targeting all relays on the network'
|
||||
: `Targeting ${relayCount} configured relay(s)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Confirmation input */}
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="vanish-confirm" className="text-sm font-medium">
|
||||
Type{' '}
|
||||
<span className="font-mono bg-destructive/10 text-destructive px-1.5 py-0.5 rounded text-xs">
|
||||
{CONFIRMATION_PHRASE}
|
||||
</span>{' '}
|
||||
to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="vanish-confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value.toUpperCase())}
|
||||
placeholder={CONFIRMATION_PHRASE}
|
||||
className={cn(
|
||||
'font-mono text-center text-lg tracking-widest transition-colors',
|
||||
isMatch && 'border-destructive/50 ring-1 ring-destructive/20',
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className={cn(
|
||||
'text-center text-xs transition-opacity duration-300',
|
||||
isMatch ? 'text-destructive opacity-100' : 'text-muted-foreground/40 opacity-0',
|
||||
)}>
|
||||
Confirmation accepted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type IframeHTMLAttributes,
|
||||
} from 'react';
|
||||
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import {
|
||||
bytesToBase64,
|
||||
utf8ToBase64,
|
||||
injectScriptTags,
|
||||
} from '@/lib/sandbox';
|
||||
import type {
|
||||
FileResponse,
|
||||
InjectedScript,
|
||||
JsonRpcResponse,
|
||||
SerialisedRequest,
|
||||
} from '@/lib/sandbox';
|
||||
import {
|
||||
SandboxPlugin,
|
||||
type SandboxFetchEvent,
|
||||
type SandboxScriptMessageEvent,
|
||||
} from '@/lib/sandboxPlugin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SandboxFrameProps
|
||||
extends Omit<IframeHTMLAttributes<HTMLIFrameElement>, 'src' | 'id'> {
|
||||
/** HMAC-derived subdomain identifier. */
|
||||
id: string;
|
||||
/**
|
||||
* Resolve a pathname to file content.
|
||||
* Return a `FileResponse` to serve the file, or `null` for a 404.
|
||||
*/
|
||||
resolveFile: (pathname: string) => Promise<FileResponse | null>;
|
||||
/**
|
||||
* Handle non-fetch, non-lifecycle JSON-RPC methods (e.g. `webxdc.*`).
|
||||
* Receives the method name, params, and a `post` function for sending
|
||||
* arbitrary messages back into the sandbox (e.g. push notifications).
|
||||
* Return the result value to send as the JSON-RPC response.
|
||||
*/
|
||||
onRpc?: (
|
||||
method: string,
|
||||
params: unknown,
|
||||
post: (msg: Record<string, unknown>) => void,
|
||||
) => Promise<unknown>;
|
||||
/**
|
||||
* Virtual scripts to inject into HTML responses.
|
||||
* Each entry is served at its `path` and a `<script src="...">` tag is
|
||||
* prepended into `<head>` of every HTML response.
|
||||
*/
|
||||
injectedScripts?: InjectedScript[];
|
||||
/** Optional Content-Security-Policy header added to every response. */
|
||||
csp?: string;
|
||||
/**
|
||||
* Called when the sandbox sends `ready`, **before** `init` is sent back.
|
||||
* If the returned promise is pending, `init` is deferred until it resolves,
|
||||
* which prevents fetch requests from arriving before the consumer is ready
|
||||
* to serve files (e.g. while an archive is still being downloaded).
|
||||
*/
|
||||
onReady?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Imperative handle exposed via ref. */
|
||||
export interface SandboxFrameHandle {
|
||||
/** Send a postMessage to the sandbox iframe. */
|
||||
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => void;
|
||||
/** Focus the iframe element. */
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fetch/RPC handler logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a serialised HTTP response and call `respond` with it.
|
||||
* Shared between the web (postMessage) and native (respondToFetch) paths.
|
||||
*/
|
||||
async function handleFetchRequest(
|
||||
pathname: string,
|
||||
resolveFile: (pathname: string) => Promise<FileResponse | null>,
|
||||
scripts: InjectedScript[],
|
||||
activeCsp: string | undefined,
|
||||
respond: (result: Record<string, unknown>) => void,
|
||||
respondError: (code: number, message: string) => void,
|
||||
): Promise<void> {
|
||||
// Check if the request is for a virtual injected script.
|
||||
const virtualScript = scripts.find(
|
||||
(s) => pathname === `/${s.path}` || pathname === s.path,
|
||||
);
|
||||
if (virtualScript) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
respond({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: utf8ToBase64(virtualScript.content),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to the consumer's file resolver.
|
||||
try {
|
||||
const file = await resolveFile(pathname);
|
||||
|
||||
if (!file) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain' };
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
|
||||
respond({
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers,
|
||||
body: utf8ToBase64('Not Found'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML responses, inject script tags.
|
||||
let bodyBase64: string;
|
||||
if (file.contentType === 'text/html' && scripts.length > 0) {
|
||||
const html = new TextDecoder().decode(file.body);
|
||||
const injected = injectScriptTags(
|
||||
html,
|
||||
scripts.map((s) => `/${s.path}`),
|
||||
);
|
||||
bodyBase64 = utf8ToBase64(injected);
|
||||
} else {
|
||||
bodyBase64 = bytesToBase64(file.body);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': file.contentType,
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (activeCsp) headers['Content-Security-Policy'] = activeCsp;
|
||||
// Include Content-Length for non-HTML (binary) responses.
|
||||
if (file.contentType !== 'text/html') {
|
||||
headers['Content-Length'] = String(file.body.byteLength);
|
||||
}
|
||||
|
||||
respond({
|
||||
status: file.status,
|
||||
statusText: 'OK',
|
||||
headers,
|
||||
body: bodyBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
respondError(-32002, String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web (iframe.diy) implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrameWeb(
|
||||
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, ...iframeProps },
|
||||
ref,
|
||||
) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const { config } = useAppContext();
|
||||
|
||||
const origin = useMemo(
|
||||
() => `https://${id}.${config.sandboxDomain}`,
|
||||
[id, config.sandboxDomain],
|
||||
);
|
||||
|
||||
// Keep latest callbacks in refs so the message handler always sees
|
||||
// current values without re-registering the listener.
|
||||
const resolveFileRef = useRef(resolveFile);
|
||||
const onRpcRef = useRef(onRpc);
|
||||
const injectedScriptsRef = useRef(injectedScripts);
|
||||
const cspRef = useRef(csp);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
useEffect(() => { resolveFileRef.current = resolveFile; }, [resolveFile]);
|
||||
useEffect(() => { onRpcRef.current = onRpc; }, [onRpc]);
|
||||
useEffect(() => { injectedScriptsRef.current = injectedScripts; }, [injectedScripts]);
|
||||
useEffect(() => { cspRef.current = csp; }, [csp]);
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Post a message to the iframe
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const post = useCallback(
|
||||
(msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, origin, transfer ?? []);
|
||||
},
|
||||
[origin],
|
||||
);
|
||||
|
||||
// Expose imperative handle.
|
||||
useImperativeHandle(ref, () => ({
|
||||
postMessage: (msg: Record<string, unknown>, transfer?: Transferable[]) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, origin, transfer ?? []);
|
||||
},
|
||||
focus: () => {
|
||||
iframeRef.current?.focus();
|
||||
},
|
||||
}), [origin]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Message handler
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (event.origin !== origin) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
|
||||
|
||||
// Notification: ready -> await onReady, then respond with init
|
||||
if (msg.method === 'ready' && msg.id === undefined) {
|
||||
handleReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// Requests (have an `id`)
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
if (msg.method === 'fetch') {
|
||||
handleFetch(msg.id, msg.params);
|
||||
} else if (onRpcRef.current) {
|
||||
handleRpc(msg.id, msg.method, msg.params ?? {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Ready handler: run consumer setup, then send init
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleReady() {
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
post({ jsonrpc: '2.0', method: 'init', params: { version: 1 } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Fetch handler
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleFetch(
|
||||
id: string | number,
|
||||
params: { request?: SerialisedRequest },
|
||||
) {
|
||||
const reqUrl = params?.request?.url;
|
||||
if (!reqUrl) {
|
||||
post({ jsonrpc: '2.0', id, error: { code: -32001, message: 'Invalid request' } });
|
||||
return;
|
||||
}
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
const url = new URL(reqUrl);
|
||||
// Only serve requests for our sandbox origin.
|
||||
if (url.origin !== origin) {
|
||||
post({ jsonrpc: '2.0', id, error: { code: -32003, message: 'Origin mismatch' } });
|
||||
return;
|
||||
}
|
||||
pathname = url.pathname;
|
||||
} catch {
|
||||
post({ jsonrpc: '2.0', id, error: { code: -32003, message: 'Invalid URL' } });
|
||||
return;
|
||||
}
|
||||
|
||||
await handleFetchRequest(
|
||||
pathname,
|
||||
resolveFileRef.current,
|
||||
injectedScriptsRef.current ?? [],
|
||||
cspRef.current,
|
||||
(result) => post({ jsonrpc: '2.0', id, result }),
|
||||
(code, message) => post({ jsonrpc: '2.0', id, error: { code, message } }),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Custom RPC handler
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleRpc(
|
||||
id: string | number,
|
||||
method: string,
|
||||
params: unknown,
|
||||
) {
|
||||
try {
|
||||
const result = await onRpcRef.current!(method, params, post);
|
||||
post({ jsonrpc: '2.0', id, result: result ?? null } satisfies JsonRpcResponse);
|
||||
} catch (err) {
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code: -1, message: String(err) },
|
||||
} satisfies JsonRpcResponse);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [origin, post]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={`${origin}/`}
|
||||
{...iframeProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native (Capacitor) implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrameNative(
|
||||
{ id, resolveFile, onRpc, injectedScripts, csp, onReady, className, style, title },
|
||||
ref,
|
||||
) {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const createdRef = useRef(false);
|
||||
const destroyedRef = useRef(false);
|
||||
|
||||
// Keep latest callbacks in refs.
|
||||
const resolveFileRef = useRef(resolveFile);
|
||||
const onRpcRef = useRef(onRpc);
|
||||
const injectedScriptsRef = useRef(injectedScripts);
|
||||
const cspRef = useRef(csp);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
useEffect(() => { resolveFileRef.current = resolveFile; }, [resolveFile]);
|
||||
useEffect(() => { onRpcRef.current = onRpc; }, [onRpc]);
|
||||
useEffect(() => { injectedScriptsRef.current = injectedScripts; }, [injectedScripts]);
|
||||
useEffect(() => { cspRef.current = csp; }, [csp]);
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Post a message into the native sandbox
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
const postToSandbox = useCallback(
|
||||
(msg: Record<string, unknown>) => {
|
||||
if (!createdRef.current || destroyedRef.current) return;
|
||||
SandboxPlugin.postMessage({ id, message: msg }).catch((err) => {
|
||||
console.error('[SandboxFrame] postMessage failed:', err);
|
||||
});
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
// Expose imperative handle.
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
postMessage: (msg: Record<string, unknown>) => {
|
||||
postToSandbox(msg);
|
||||
},
|
||||
focus: () => {
|
||||
// No-op on native — the WebView is overlaid, not an iframe.
|
||||
},
|
||||
}),
|
||||
[postToSandbox],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Lifecycle: onReady -> create WebView -> listen for events -> destroy
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
if (createdRef.current) return;
|
||||
|
||||
const listeners: Array<{ remove: () => void }> = [];
|
||||
let cancelled = false;
|
||||
|
||||
async function setup() {
|
||||
// Measure the placeholder position.
|
||||
const el = placeholderRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Register listeners BEFORE creating the WebView. On Android,
|
||||
// `shouldInterceptRequest` fires on a background thread as soon
|
||||
// as the WebView starts loading — if the fetch listener isn't
|
||||
// registered yet, the event is lost and the request times out
|
||||
// (the thread blocks via CountDownLatch waiting for a response
|
||||
// that never arrives).
|
||||
const fetchListener = await SandboxPlugin.addListener(
|
||||
'fetch',
|
||||
(event: SandboxFetchEvent) => {
|
||||
if (event.id !== id) return;
|
||||
handleNativeFetch(event);
|
||||
},
|
||||
);
|
||||
listeners.push(fetchListener);
|
||||
|
||||
const scriptListener = await SandboxPlugin.addListener(
|
||||
'scriptMessage',
|
||||
(event: SandboxScriptMessageEvent) => {
|
||||
if (event.id !== id) return;
|
||||
handleNativeScriptMessage(event);
|
||||
},
|
||||
);
|
||||
listeners.push(scriptListener);
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Create the native WebView with a loading spinner — does NOT
|
||||
// navigate yet, so no fetch events fire at this point.
|
||||
await SandboxPlugin.create({
|
||||
id,
|
||||
frame: {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled || destroyedRef.current) {
|
||||
SandboxPlugin.destroy({ id }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
createdRef.current = true;
|
||||
|
||||
// Run onReady while the spinner is visible and animating.
|
||||
// On Android this pre-fetches all blobs so every resolveFile call
|
||||
// after navigation is an instant cache hit.
|
||||
// On iOS/web this is typically a no-op or instant.
|
||||
try {
|
||||
await onReadyRef.current?.();
|
||||
} catch (err) {
|
||||
console.error('[SandboxFrame] onReady failed:', err);
|
||||
}
|
||||
|
||||
if (cancelled || destroyedRef.current) return;
|
||||
|
||||
// Start loading the sandbox content — fetch events will now fire
|
||||
// and be handled by the listeners registered above.
|
||||
await SandboxPlugin.navigate({ id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Handle a fetch request from the native WebView
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleNativeFetch(event: SandboxFetchEvent) {
|
||||
const reqUrl = event.request.url;
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(reqUrl).pathname;
|
||||
} catch {
|
||||
// The native handler rewrites custom-scheme URLs to
|
||||
// https://<id>.sandbox.native/<path> so we can parse them.
|
||||
// If that fails, try extracting the path directly.
|
||||
const pathMatch = reqUrl.match(/\/\/[^/]+(\/.*)/);
|
||||
pathname = pathMatch?.[1] ?? '/';
|
||||
}
|
||||
|
||||
await handleFetchRequest(
|
||||
pathname,
|
||||
resolveFileRef.current,
|
||||
injectedScriptsRef.current ?? [],
|
||||
cspRef.current,
|
||||
(result) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
id,
|
||||
requestId: event.requestId,
|
||||
response: result as {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[SandboxFrame] respondToFetch failed:', err);
|
||||
});
|
||||
},
|
||||
(_code, message) => {
|
||||
SandboxPlugin.respondToFetch({
|
||||
id,
|
||||
requestId: event.requestId,
|
||||
response: {
|
||||
status: 500,
|
||||
statusText: 'Internal Error',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: btoa(message),
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('[SandboxFrame] respondToFetch error failed:', err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Handle a script message from the native WebView
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
async function handleNativeScriptMessage(event: SandboxScriptMessageEvent) {
|
||||
const msg = event.message;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rpc = msg as any;
|
||||
if (rpc.jsonrpc !== '2.0') return;
|
||||
|
||||
// Handle RPC requests (have both `id` and `method`).
|
||||
if (rpc.id !== undefined && rpc.method && onRpcRef.current) {
|
||||
try {
|
||||
const result = await onRpcRef.current(
|
||||
rpc.method,
|
||||
rpc.params ?? {},
|
||||
postToSandbox,
|
||||
);
|
||||
postToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
id: rpc.id,
|
||||
result: result ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
postToSandbox({
|
||||
jsonrpc: '2.0',
|
||||
id: rpc.id,
|
||||
error: { code: -1, message: String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setup().catch((err) => {
|
||||
console.error('[SandboxFrame] native setup failed:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
destroyedRef.current = true;
|
||||
for (const listener of listeners) {
|
||||
listener.remove();
|
||||
}
|
||||
if (createdRef.current) {
|
||||
SandboxPlugin.destroy({ id }).catch((err) => {
|
||||
console.error('[SandboxFrame] destroy failed:', err);
|
||||
});
|
||||
createdRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [id, postToSandbox]);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Keep frame in sync with placeholder size/position
|
||||
//
|
||||
// Both consumers (WebxdcEmbed, NsitePreviewDialog) render inside
|
||||
// position:fixed panels, so the placeholder never moves on scroll.
|
||||
// A ResizeObserver is sufficient to track layout changes.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const el = placeholderRef.current;
|
||||
if (!el) return;
|
||||
|
||||
function updateFrame() {
|
||||
if (!createdRef.current || destroyedRef.current) return;
|
||||
const rect = el!.getBoundingClientRect();
|
||||
SandboxPlugin.updateFrame({
|
||||
id,
|
||||
frame: {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
},
|
||||
}).catch(() => {
|
||||
// Ignore — WebView may not be created yet.
|
||||
});
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(updateFrame);
|
||||
ro.observe(el);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={placeholderRef}
|
||||
className={className}
|
||||
style={style}
|
||||
title={title}
|
||||
data-sandbox-id={id}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public component — delegates to web or native implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders a sandboxed content frame.
|
||||
*
|
||||
* On web, this creates an iframe on a unique subdomain (`<id>.<sandboxDomain>`)
|
||||
* and implements the iframe.diy handshake + fetch proxy protocol.
|
||||
*
|
||||
* On native platforms (iOS/Android via Capacitor), this creates a native
|
||||
* WKWebView/WebView overlay with a custom URL scheme handler that intercepts
|
||||
* all requests and routes them through the same `resolveFile` callback.
|
||||
*
|
||||
* All file serving is delegated to the `resolveFile` callback.
|
||||
* Custom RPC methods are delegated to the optional `onRpc` callback.
|
||||
* Consumers (Webxdc, NsitePreviewDialog) are platform-agnostic.
|
||||
*/
|
||||
export const SandboxFrame = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
function SandboxFrame(props, ref) {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
return <SandboxFrameNative ref={ref} {...props} />;
|
||||
}
|
||||
return <SandboxFrameWeb ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export default SandboxFrame;
|
||||
@@ -85,7 +85,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
|
||||
// Measure safe-area-inset-top once by reading it via a throw-away element.
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:fixed;top:var(--safe-area-inset-top,env(safe-area-inset-top,0px));left:0;width:0;height:0;visibility:hidden;pointer-events:none';
|
||||
probe.style.cssText = 'position:fixed;top:env(safe-area-inset-top,0px);left:0;width:0;height:0;visibility:hidden;pointer-events:none';
|
||||
document.body.appendChild(probe);
|
||||
const safeAreaTop = probe.getBoundingClientRect().top;
|
||||
document.body.removeChild(probe);
|
||||
@@ -122,7 +122,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
{showSafeAreaPadding && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 bg-background/85 sidebar:hidden"
|
||||
style={{ height: 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))' }}
|
||||
style={{ height: 'env(safe-area-inset-top, 0px)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Inner wrapper so ArcBackground covers only the tab area, not the safe-area padding above.
|
||||
@@ -167,9 +167,9 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
type="button"
|
||||
aria-label="Scroll tabs left"
|
||||
onClick={() => scrollBy('left')}
|
||||
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background via-background to-transparent cursor-pointer"
|
||||
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background/90 to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
|
||||
<ChevronLeft className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
@@ -184,9 +184,9 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
type="button"
|
||||
aria-label="Scroll tabs right"
|
||||
onClick={() => scrollBy('right')}
|
||||
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background via-background to-transparent cursor-pointer"
|
||||
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background/90 to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="size-4 text-foreground/60 drop-shadow-md" strokeWidth={4} />
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user