Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 283b31813c | |||
| 6e1197a067 | |||
| b7d1fbf860 | |||
| 8fde660075 | |||
| 50c7d67928 | |||
| e355c43925 | |||
| 696204870d | |||
| 0a7e01d17c | |||
| dd87bc96ec | |||
| a12d5db560 | |||
| 614634789c | |||
| 29696fa3d3 | |||
| ffc31e8e8f | |||
| 720a7e91fe | |||
| 05096e2cd9 | |||
| 05667460eb | |||
| b10dae7655 | |||
| c799b9efd6 | |||
| fe4834e157 | |||
| 5d972249a4 | |||
| f607a01577 | |||
| 1e232e6a9e | |||
| 431c388129 | |||
| 72b63dac21 | |||
| be82cb9626 | |||
| c2c6f711b5 | |||
| 3fba81a7d2 | |||
| 6f2b51197f | |||
| 00c801e9dc | |||
| 47e7d05cb9 | |||
| 4ef6d1b149 | |||
| badd19d27c | |||
| e67f90582b | |||
| 7fa6e574f8 | |||
| 9b36bf3325 | |||
| bc1c4cb7cf | |||
| 119f684fb3 | |||
| 45134ef9cc | |||
| db502b462c | |||
| ed083bfdad | |||
| 47811f9190 | |||
| ba99cdc51c | |||
| 7092f7306f | |||
| 357dd56de0 | |||
| fadec0574a | |||
| 469806886a | |||
| f7ab980ecd | |||
| c6b5ab2284 | |||
| 2231673ee6 | |||
| f8907475f9 | |||
| 4252841125 | |||
| ee8220c1f0 | |||
| 11e29646a7 | |||
| a9bab7f8e8 | |||
| 0b69ab51f4 | |||
| 2a32e79b13 | |||
| 39fc7549ac | |||
| 414f42e339 | |||
| 8e3f778f5b | |||
| bc83d08961 | |||
| 7d83273410 | |||
| fabcb4170d | |||
| 8b824f8cc9 | |||
| 3e429fe0b0 | |||
| a261934ab0 | |||
| 822ff13ac3 | |||
| afa475ecef | |||
| 853b5ead9c | |||
| a5746ee915 | |||
| fa3376ac4f | |||
| 6f0c10fe9b | |||
| 2f1bf0bca5 | |||
| 9be98d9a8d | |||
| c4dd8e7c3d | |||
| 42832b72e3 | |||
| e77436d02a | |||
| 302d7732ef | |||
| b09b4938d2 | |||
| 0a0d6de111 | |||
| 4e9b893822 | |||
| c60e87ad65 | |||
| 8e07ad515a | |||
| b4c4b8eb21 | |||
| 23ee6f1196 | |||
| 4b97baa428 | |||
| c8e844a19a | |||
| 205a252cac | |||
| ad604eae68 | |||
| 57064b4f40 | |||
| bb7b8da581 | |||
| 5683f6ea1e | |||
| 61c606822a | |||
| bc12331cd4 | |||
| 2478bf1c66 | |||
| ade9eb4999 | |||
| 0f02563d3a | |||
| 38630be23d | |||
| 9b8cff63da | |||
| e13473809d | |||
| 00a9ad20de | |||
| d28364531b | |||
| f3eb4adba5 | |||
| 0487586af9 | |||
| 2c737ca322 | |||
| c9823055fd | |||
| d2cd5f22bf |
+2
-1
@@ -54,7 +54,6 @@ deploy-nsite:
|
||||
--relays "wss://relay.ditto.pub,wss://relay.nsite.lol,wss://relay.dreamith.to,wss://relay.primal.net"
|
||||
--servers "https://blossom.primal.net,https://blossom.ditto.pub,https://blossom.dreamith.to"
|
||||
--fallback "/index.html"
|
||||
--publish-server-list
|
||||
--use-fallback-relays
|
||||
--use-fallback-servers
|
||||
|
||||
@@ -203,6 +202,8 @@ publish-zapstore:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
SIGN_WITH: $ZAPSTORE_BUNKER_URL
|
||||
RELAY_URLS: "wss://relay.zapstore.dev,wss://relay.ditto.pub"
|
||||
BLOSSOM_URL: "https://blossom.ditto.pub"
|
||||
script:
|
||||
- go install github.com/zapstore/zsp@latest
|
||||
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.3.0"
|
||||
versionName "2.6.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +325,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+61
-36
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.2.11",
|
||||
"version": "2.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.2.11",
|
||||
"version": "2.5.2",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -58,8 +58,8 @@
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.0",
|
||||
"@nostrify/react": "^0.4.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -2486,9 +2486,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify": {
|
||||
"version": "0.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.0.tgz",
|
||||
"integrity": "sha512-GLka8FHu7o04kpz/NB69JppQy3rbwkadr8Au2fLmYbbB478kkGuthF+U5JS2qKaAI137n1p5BN1eFsCk2JyuXQ==",
|
||||
"version": "0.51.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.1.tgz",
|
||||
"integrity": "sha512-oPJhUiO1TlV5sGYizqAP4GvLijib34Uwh48wxlFimR/2MoCuSmab4AppcztGPNwxQoTKkJbLJwsSpl42V+WIXA==",
|
||||
"dependencies": {
|
||||
"@nostrify/types": "0.36.9",
|
||||
"@scure/base": "^2.0.0",
|
||||
@@ -2512,9 +2512,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify/node_modules/@types/node": {
|
||||
"version": "24.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
|
||||
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -2527,11 +2527,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.0.tgz",
|
||||
"integrity": "sha512-noroI4R2BS3GzEk55NGoWZkrBKDFHtM43HW99dnYdP+ecxtjBY6nYplypouUUkalHfTfGK2lQetLg5DvM2k2+w==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.4.1.tgz",
|
||||
"integrity": "sha512-2JXxEl4e6FIFhbi96Dwv2knu5qAACYulo1a0oVell/aS8KCWsBTPd1+v0EUra0yqiUA3Q1nVLrk8mx7kQYH/yQ==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.0",
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5699,6 +5699,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5712,6 +5713,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5725,6 +5727,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5738,6 +5741,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5751,6 +5755,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5764,6 +5769,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5777,6 +5783,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5790,6 +5797,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5803,6 +5811,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5816,6 +5825,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5829,6 +5839,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5842,6 +5853,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5855,6 +5867,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5868,6 +5881,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5881,6 +5895,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5894,6 +5909,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5907,6 +5923,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5920,6 +5937,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5933,6 +5951,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5946,6 +5965,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5959,6 +5979,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5972,6 +5993,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5985,6 +6007,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5998,6 +6021,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6011,6 +6035,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6160,9 +6185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/is-array-buffer": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz",
|
||||
"integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
|
||||
"integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -6172,13 +6197,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-base64": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz",
|
||||
"integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz",
|
||||
"integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/util-buffer-from": "^4.2.1",
|
||||
"@smithy/util-utf8": "^4.2.1",
|
||||
"@smithy/util-buffer-from": "^4.2.2",
|
||||
"@smithy/util-utf8": "^4.2.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6186,12 +6211,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-buffer-from": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz",
|
||||
"integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
|
||||
"integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/is-array-buffer": "^4.2.1",
|
||||
"@smithy/is-array-buffer": "^4.2.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6199,9 +6224,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-hex-encoding": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz",
|
||||
"integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz",
|
||||
"integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -6211,12 +6236,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-utf8": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz",
|
||||
"integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
|
||||
"integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/util-buffer-from": "^4.2.1",
|
||||
"@smithy/util-buffer-from": "^4.2.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15460,9 +15485,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/websocket-ts": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.2.1.tgz",
|
||||
"integrity": "sha512-YKPDfxlK5qOheLZ2bTIiktZO1bpfGdNCPJmTEaPW7G9UXI1GKjDdeacOrsULUS000OPNxDVOyAuKLuIWPqWM0Q==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/websocket-ts/-/websocket-ts-2.3.0.tgz",
|
||||
"integrity": "sha512-DocKMdXx7i8TCBMU+XUKZeUaKwQ7O2NPlxUcgb0poG4RwDrIqBo19mRdW00a1Sm7MSijhIEsgv9UJ0kB/qNy+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -64,8 +64,8 @@
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.0",
|
||||
"@nostrify/react": "^0.4.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.4.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
+5
-4
@@ -51,6 +51,7 @@ const hardcodedConfig: AppConfig = {
|
||||
appName: "Ditto",
|
||||
appId: "ditto",
|
||||
homePage: "feed",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
autoShareTheme: true,
|
||||
@@ -123,11 +124,11 @@ const hardcodedConfig: AppConfig = {
|
||||
"feed",
|
||||
"notifications",
|
||||
"search",
|
||||
"themes",
|
||||
"letters",
|
||||
"badges",
|
||||
"blobbi",
|
||||
"theme",
|
||||
"badges",
|
||||
"emojis",
|
||||
"letters",
|
||||
"themes",
|
||||
"settings",
|
||||
"help",
|
||||
],
|
||||
|
||||
@@ -77,6 +77,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -151,6 +152,9 @@ export function AppRouter() {
|
||||
</Suspense>
|
||||
</BlobbiActionsProvider>
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
// src/blobbi/actions/components/BlobbiMissionsModal.tsx
|
||||
|
||||
/**
|
||||
* Missions modal for Blobbi.
|
||||
*
|
||||
* Shows:
|
||||
* - Daily missions (always visible, separate reward system)
|
||||
* - Incubation tasks when the current Blobbi is incubating (egg stage)
|
||||
* - Evolve tasks when evolving (baby stage)
|
||||
* Missions modal for Blobbi — card-grid quest board.
|
||||
*
|
||||
* Layout:
|
||||
* 1. Sticky header with title, subtitle, legend help button, close
|
||||
* 2. Current Focus section (hatch / evolve) — collapsible, default open
|
||||
* 3. Daily Bounties section — collapsible, default open
|
||||
* 4. Settings row — low emphasis toggle (not collapsible)
|
||||
*
|
||||
* Both main sections use lightweight Radix Collapsible wrappers.
|
||||
* Collapsed headers still show summary info (progress / coins).
|
||||
*/
|
||||
|
||||
import { Target, Loader2, XCircle, AlertTriangle, Calendar, Coins, X, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Coins,
|
||||
X,
|
||||
Eye,
|
||||
Scroll,
|
||||
Compass,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { formatCompactNumber, cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogClose } from '@/components/ui/dialog';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -24,7 +44,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
@@ -42,36 +61,86 @@ import { useRerollMission } from '../hooks/useRerollMission';
|
||||
interface BlobbiMissionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Current companion being viewed */
|
||||
companion: BlobbiCompanion;
|
||||
/** Current Blobbonaut profile (required for coin updates) */
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Callback to update profile in query cache after claiming */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Hatch tasks result from useHatchTasks */
|
||||
hatchTasks: HatchTasksResult;
|
||||
/** Evolve tasks result from useEvolveTasks */
|
||||
evolveTasks: EvolveTasksResult;
|
||||
/** Called when user clicks "Create Post" action in tasks */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all hatch tasks are complete and user clicks "Hatch" */
|
||||
onHatch: () => void;
|
||||
/** Whether hatching is in progress */
|
||||
isHatching: boolean;
|
||||
/** Called when all evolve tasks are complete and user clicks "Evolve" */
|
||||
onEvolve: () => void;
|
||||
/** Whether evolving is in progress */
|
||||
isEvolving: boolean;
|
||||
/** Called when user confirms stopping incubation */
|
||||
onStopIncubation: () => Promise<void>;
|
||||
/** Whether stop incubation is in progress */
|
||||
isStoppingIncubation: boolean;
|
||||
/** Called when user confirms stopping evolution */
|
||||
onStopEvolution: () => Promise<void>;
|
||||
/** Whether stop evolution is in progress */
|
||||
isStoppingEvolution: boolean;
|
||||
/** Available Blobbi stages across all user's companions (for mission filtering) */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
showMissionCard?: boolean;
|
||||
onToggleMissionCard?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
// ─── Section Chevron ─────────────────────────────────────────────────────────
|
||||
|
||||
function SectionChevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 text-muted-foreground/60 transition-transform duration-200',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mission Type Legend ──────────────────────────────────────────────────────
|
||||
|
||||
function MissionTypeLegend() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 opacity-50 hover:opacity-100 hover:bg-muted transition-all"
|
||||
aria-label="Mission types legend"
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" className="w-56 p-3">
|
||||
<p className="text-xs font-semibold mb-2">Mission Types</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
|
||||
<Scroll className="size-3 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Daily Bounty</p>
|
||||
<p className="text-[10px] text-muted-foreground">Resets every day</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-sky-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs">🥚</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Hatch Task</p>
|
||||
<p className="text-[10px] text-muted-foreground">Egg progression</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs">🐣</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">Evolve Task</p>
|
||||
<p className="text-[10px] text-muted-foreground">Baby progression</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Missions Section ───────────────────────────────────────────────────
|
||||
@@ -79,14 +148,20 @@ interface BlobbiMissionsModalProps {
|
||||
interface DailyMissionsSectionProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Available Blobbi stages the user has */
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DailyMissionsSection({ profile, updateProfileEvent, availableStages, disabled, defaultOpen = true }: DailyMissionsSectionProps) {
|
||||
function DailyMissionsSection({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
availableStages,
|
||||
disabled,
|
||||
defaultOpen = true,
|
||||
}: DailyMissionsSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const {
|
||||
missions,
|
||||
todayClaimedReward,
|
||||
@@ -100,58 +175,56 @@ function DailyMissionsSection({ profile, updateProfileEvent, availableStages, di
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent
|
||||
updateProfileEvent,
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
|
||||
const handleClaimReward = (missionId: string) => {
|
||||
claimReward({ missionId });
|
||||
};
|
||||
|
||||
const handleRerollMission = (missionId: string) => {
|
||||
rerollMission({ missionId, availableStages });
|
||||
};
|
||||
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Section header — tappable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center justify-between py-1 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 text-primary shrink-0" />
|
||||
<h3 className="font-semibold text-sm">Daily Missions</h3>
|
||||
<Scroll className="size-4 text-amber-500 dark:text-amber-400 shrink-0" />
|
||||
<h3 className="font-semibold text-sm">Daily Bounties</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{/* Summary pill — always visible */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
<span className="tabular-nums">
|
||||
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
|
||||
</span>
|
||||
{claimableCount > 0 && (
|
||||
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
|
||||
{claimableCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
<SectionChevron open={isOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Mission list */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={handleClaimReward}
|
||||
onRerollMission={handleRerollMission}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<div className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={(id) => claimReward({ missionId: id })}
|
||||
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
@@ -224,9 +297,9 @@ function StopConfirmationDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Process Content (Incubation or Evolution) ────────────────────────────────
|
||||
// ─── Current Focus Section (Hatch / Evolve) ──────────────────────────────────
|
||||
|
||||
interface ProcessContentProps {
|
||||
interface CurrentFocusSectionProps {
|
||||
companion: BlobbiCompanion;
|
||||
tasks: HatchTasksResult | EvolveTasksResult;
|
||||
processType: 'incubation' | 'evolution';
|
||||
@@ -238,7 +311,7 @@ interface ProcessContentProps {
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function ProcessContent({
|
||||
function CurrentFocusSection({
|
||||
companion,
|
||||
tasks,
|
||||
processType,
|
||||
@@ -248,93 +321,98 @@ function ProcessContent({
|
||||
onStop,
|
||||
isStopping,
|
||||
defaultOpen = true,
|
||||
}: ProcessContentProps) {
|
||||
}: CurrentFocusSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [showStopConfirmation, setShowStopConfirmation] = useState(false);
|
||||
|
||||
const isIncubation = processType === 'incubation';
|
||||
const emoji = isIncubation ? '🥚' : '🐣';
|
||||
const title = isIncubation ? 'Hatch Tasks' : 'Evolve Tasks';
|
||||
const description = isIncubation
|
||||
? 'Complete these tasks to hatch your Blobbi'
|
||||
: 'Complete these tasks to evolve your Blobbi';
|
||||
const completeLabel = isIncubation ? 'Hatch Your Blobbi!' : 'Evolve Your Blobbi!';
|
||||
const completingLabel = isIncubation ? 'Hatching...' : 'Evolving...';
|
||||
const completeEmoji = isIncubation ? '🐣' : '✨';
|
||||
const stopLabel = isIncubation ? 'Stop Incubation' : 'Stop Evolution';
|
||||
const badgeLabel = isIncubation ? 'Hatch' : 'Evolve';
|
||||
const category = isIncubation ? ('hatch' as const) : ('evolve' as const);
|
||||
|
||||
const completedCount = tasks.tasks.filter(t => t.completed).length;
|
||||
const completedCount = tasks.tasks.filter((t) => t.completed).length;
|
||||
const totalTasks = tasks.tasks.length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
{/* Section header - Clickable */}
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Section header — tappable */}
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="flex items-center justify-between gap-2 p-3 rounded-lg bg-muted/50 hover:bg-muted/70 transition-colors">
|
||||
<div className="flex items-center justify-between py-1 group">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{emoji}</span>
|
||||
<h3 className="font-semibold text-sm">{title}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs font-semibold px-2 py-0.5',
|
||||
isIncubation
|
||||
? 'bg-sky-500/15 text-sky-600 dark:text-sky-400'
|
||||
: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
|
||||
)}
|
||||
>
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full",
|
||||
tasks.allCompleted
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{completedCount}/{totalTasks}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium tabular-nums',
|
||||
tasks.allCompleted
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{completedCount} / {totalTasks}
|
||||
</span>
|
||||
<ChevronDown className={cn(
|
||||
"size-4 text-muted-foreground transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)} />
|
||||
<SectionChevron open={isOpen} />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Tasks content */}
|
||||
<CollapsibleContent className="pt-3">
|
||||
{/* Tasks Panel */}
|
||||
<TasksPanel
|
||||
tasks={tasks.tasks}
|
||||
allCompleted={tasks.allCompleted}
|
||||
isLoading={tasks.isLoading}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onComplete}
|
||||
isCompleting={isCompleting}
|
||||
emoji={emoji}
|
||||
title={title}
|
||||
description={description}
|
||||
completeLabel={completeLabel}
|
||||
completingLabel={completingLabel}
|
||||
completeEmoji={completeEmoji}
|
||||
/>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<div className="pt-3">
|
||||
{/* Task card grid */}
|
||||
<TasksPanel
|
||||
tasks={tasks.tasks}
|
||||
allCompleted={tasks.allCompleted}
|
||||
isLoading={tasks.isLoading}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
onComplete={onComplete}
|
||||
isCompleting={isCompleting}
|
||||
completeLabel={completeLabel}
|
||||
completingLabel={completingLabel}
|
||||
completeEmoji={completeEmoji}
|
||||
category={category}
|
||||
/>
|
||||
|
||||
{/* Stop Process Button */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStopConfirmation(true)}
|
||||
disabled={isStopping || isCompleting}
|
||||
className="w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-4 mr-2" />
|
||||
{stopLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Stop process — low emphasis */}
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowStopConfirmation(true)}
|
||||
disabled={isStopping || isCompleting}
|
||||
className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 h-8 px-3"
|
||||
>
|
||||
{isStopping ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
Stopping...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-3.5 mr-1.5" />
|
||||
{stopLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
{/* Stop Confirmation Dialog */}
|
||||
<StopConfirmationDialog
|
||||
open={showStopConfirmation}
|
||||
onOpenChange={setShowStopConfirmation}
|
||||
@@ -347,6 +425,17 @@ function ProcessContent({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty Focus State ────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyFocusState() {
|
||||
return (
|
||||
<div className="py-6 text-center">
|
||||
<Compass className="size-5 text-muted-foreground/50 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No active progression right now</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiMissionsModal({
|
||||
@@ -367,54 +456,46 @@ export function BlobbiMissionsModal({
|
||||
onStopEvolution,
|
||||
isStoppingEvolution,
|
||||
availableStages,
|
||||
showMissionCard,
|
||||
onToggleMissionCard,
|
||||
}: BlobbiMissionsModalProps) {
|
||||
const isIncubating = companion.state === 'incubating';
|
||||
const isEvolvingState = companion.state === 'evolving';
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isBaby = companion.stage === 'baby';
|
||||
|
||||
// Check if there's an active hatch/evolve process
|
||||
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
|
||||
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Target className="size-5 shrink-0" />
|
||||
Missions
|
||||
</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
Complete missions to earn rewards for {companion.name}
|
||||
</DialogDescription>
|
||||
{/* ── Sticky Header ── */}
|
||||
<div className="sticky top-0 z-10 bg-background px-4 sm:px-5 pt-4 pb-3 border-b border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-bold tracking-tight">Missions</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Quests & bounties for {companion.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<MissionTypeLegend />
|
||||
<DialogClose className="rounded-full p-1.5 opacity-60 hover:opacity-100 hover:bg-muted transition-all">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-6 py-3 sm:py-4 space-y-4">
|
||||
{/* Daily Missions Section - Always visible, expanded by default */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
|
||||
{/* Hatch/Evolve Process Section - Only when active, expanded by default */}
|
||||
{hasActiveProcess && (
|
||||
{/* ── Scrollable Content ── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 sm:px-5 py-4 space-y-5">
|
||||
{/* 1. Current Focus */}
|
||||
{hasActiveProcess ? (
|
||||
<>
|
||||
{isIncubating && isEgg ? (
|
||||
<ProcessContent
|
||||
<CurrentFocusSection
|
||||
companion={companion}
|
||||
tasks={hatchTasks}
|
||||
processType="incubation"
|
||||
@@ -423,10 +504,9 @@ export function BlobbiMissionsModal({
|
||||
isCompleting={isHatching}
|
||||
onStop={onStopIncubation}
|
||||
isStopping={isStoppingIncubation}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : isEvolvingState && isBaby ? (
|
||||
<ProcessContent
|
||||
<CurrentFocusSection
|
||||
companion={companion}
|
||||
tasks={evolveTasks}
|
||||
processType="evolution"
|
||||
@@ -435,10 +515,43 @@ export function BlobbiMissionsModal({
|
||||
isCompleting={isEvolving}
|
||||
onStop={onStopEvolution}
|
||||
isStopping={isStoppingEvolution}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyFocusState />
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* 2. Daily Bounties */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
/>
|
||||
|
||||
{/* 3. Settings */}
|
||||
{onToggleMissionCard !== undefined && showMissionCard !== undefined && (
|
||||
<>
|
||||
<div className="h-px bg-border/40" />
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label
|
||||
htmlFor="mission-card-toggle"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Show mission card on main page
|
||||
</Label>
|
||||
<Switch
|
||||
id="mission-card-toggle"
|
||||
checked={showMissionCard}
|
||||
onCheckedChange={onToggleMissionCard}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { toast } from '@/hooks/useToast';
|
||||
|
||||
import {
|
||||
BLOBBI_POST_REQUIRED_HASHTAGS,
|
||||
buildHatchPhrase,
|
||||
} from '../hooks/useHatchTasks';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -49,33 +50,13 @@ interface BlobbiPostModalProps {
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize a name into a valid hashtag format.
|
||||
* - Removes special characters
|
||||
* - Replaces spaces with nothing (camelCase-like)
|
||||
* - Ensures lowercase
|
||||
* - Handles edge cases
|
||||
*/
|
||||
function sanitizeToHashtag(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove emojis and special characters, keep letters, numbers, underscores
|
||||
.replace(/[^\p{L}\p{N}_]/gu, '')
|
||||
// Ensure it starts with a letter (prepend 'blobbi' if it starts with number)
|
||||
.replace(/^(\d)/, 'blobbi$1')
|
||||
// Limit length
|
||||
.slice(0, 30)
|
||||
// Fallback if empty
|
||||
|| 'myblobbi';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the required prefix text based on process type.
|
||||
*/
|
||||
function buildPrefix(process: BlobbiPostProcess): string {
|
||||
return process === 'evolve'
|
||||
? 'Hello Nostr! Posting to evolve'
|
||||
: 'Hello Nostr! Posting to hatch';
|
||||
? 'Posting to evolve'
|
||||
: 'Posting to hatch';
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
@@ -91,20 +72,19 @@ export function BlobbiPostModal({
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
|
||||
// Compute the required elements based on props
|
||||
const blobbiHashtag = useMemo(() => sanitizeToHashtag(blobbiName), [blobbiName]);
|
||||
const prefix = useMemo(() => buildPrefix(process), [process]);
|
||||
const capitalizedName = useMemo(() => blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1), [blobbiName]);
|
||||
|
||||
// All required hashtags including the Blobbi name (first)
|
||||
const allRequiredHashtags = useMemo(() =>
|
||||
[blobbiHashtag, ...BLOBBI_POST_REQUIRED_HASHTAGS],
|
||||
[blobbiHashtag]
|
||||
// The required phrase that must appear in the post
|
||||
const requiredPhrase = useMemo(() =>
|
||||
process === 'hatch'
|
||||
? buildHatchPhrase(blobbiName)
|
||||
: `${prefix} ${capitalizedName} #blobbi`,
|
||||
[process, blobbiName, prefix, capitalizedName]
|
||||
);
|
||||
|
||||
// Build default content
|
||||
const defaultContent = useMemo(() =>
|
||||
`${prefix} #${allRequiredHashtags.join(' #')}`,
|
||||
[prefix, allRequiredHashtags]
|
||||
);
|
||||
// Build default content (the phrase itself is enough)
|
||||
const defaultContent = useMemo(() => requiredPhrase, [requiredPhrase]);
|
||||
|
||||
const [content, setContent] = useState(defaultContent);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
@@ -118,24 +98,14 @@ export function BlobbiPostModal({
|
||||
}, [open, defaultContent]);
|
||||
|
||||
/**
|
||||
* Validate that the content still contains the required prefix and hashtags.
|
||||
* Validate that the content contains the required phrase.
|
||||
*/
|
||||
const validateContent = useCallback((text: string): string | null => {
|
||||
// Check prefix
|
||||
if (!text.startsWith(prefix)) {
|
||||
return 'The post must start with the required text';
|
||||
if (!text.includes(requiredPhrase)) {
|
||||
return `The post must contain: "${requiredPhrase}"`;
|
||||
}
|
||||
|
||||
// Check all required hashtags are present (including Blobbi name)
|
||||
const lowerText = text.toLowerCase();
|
||||
for (const tag of allRequiredHashtags) {
|
||||
if (!lowerText.includes(`#${tag.toLowerCase()}`)) {
|
||||
return `Missing required hashtag: #${tag}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [prefix, allRequiredHashtags]);
|
||||
}, [requiredPhrase]);
|
||||
|
||||
/**
|
||||
* Handle content change with validation.
|
||||
@@ -180,21 +150,26 @@ export function BlobbiPostModal({
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tags for the post
|
||||
// Build tags for the post: extract all hashtags from content
|
||||
const tags: string[][] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Add all required hashtags as 't' tags
|
||||
for (const hashtag of allRequiredHashtags) {
|
||||
tags.push(['t', hashtag.toLowerCase()]);
|
||||
// Always include BLOBBI_POST_REQUIRED_HASHTAGS as t tags
|
||||
for (const hashtag of BLOBBI_POST_REQUIRED_HASHTAGS) {
|
||||
const lower = hashtag.toLowerCase();
|
||||
if (!seen.has(lower)) {
|
||||
tags.push(['t', lower]);
|
||||
seen.add(lower);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract any additional hashtags the user added
|
||||
const additionalHashtags = content.match(/#(\w+)/g) || [];
|
||||
const requiredLower = allRequiredHashtags.map(t => t.toLowerCase());
|
||||
for (const tag of additionalHashtags) {
|
||||
// Extract any additional hashtags from the content
|
||||
const contentHashtags = content.match(/#(\w+)/g) || [];
|
||||
for (const tag of contentHashtags) {
|
||||
const tagValue = tag.slice(1).toLowerCase();
|
||||
if (!requiredLower.includes(tagValue)) {
|
||||
if (!seen.has(tagValue)) {
|
||||
tags.push(['t', tagValue]);
|
||||
seen.add(tagValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +195,7 @@ export function BlobbiPostModal({
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, allRequiredHashtags, process]);
|
||||
}, [user, content, validateContent, createEvent, onOpenChange, onSuccess, process]);
|
||||
|
||||
const canPost = !validationError && content.trim().length > 0;
|
||||
|
||||
@@ -282,13 +257,9 @@ export function BlobbiPostModal({
|
||||
|
||||
{/* Preview of required content */}
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-dashed">
|
||||
<p className="text-xs text-muted-foreground mb-1">Required content:</p>
|
||||
<p className="text-sm font-medium">
|
||||
<span className="text-primary">{prefix}</span>
|
||||
{' '}
|
||||
{allRequiredHashtags.map(tag => (
|
||||
<span key={tag} className="text-blue-500">#{tag} </span>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground mb-1">Required phrase:</p>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,285 +1,164 @@
|
||||
/**
|
||||
* DailyMissionsPanel - UI component for displaying daily missions
|
||||
*
|
||||
* Shows:
|
||||
* - Daily mission list with progress bars
|
||||
* - Completion state
|
||||
* - Claim buttons for completed missions
|
||||
* - Coin rewards
|
||||
* - Bonus mission after completing all regular missions
|
||||
* - Empty state when no missions available (egg-only users)
|
||||
* - Reroll button to replace missions (max 3/day)
|
||||
* DailyMissionsPanel — card-grid layout for daily bounties.
|
||||
*
|
||||
* Each mission is a compact card in a 2-col grid.
|
||||
* Tapping a card expands it to show progress, claim button, and reroll.
|
||||
* Only one card expanded at a time.
|
||||
*/
|
||||
|
||||
import { Check, Coins, Gift, Sparkles, Egg, Trophy, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Coins,
|
||||
Gift,
|
||||
Sparkles,
|
||||
Egg,
|
||||
Trophy,
|
||||
RefreshCw,
|
||||
Heart,
|
||||
Utensils,
|
||||
Droplets,
|
||||
Moon,
|
||||
Camera,
|
||||
Mic,
|
||||
Music,
|
||||
Pill,
|
||||
CircleDot,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import type { DailyMission } from '../lib/daily-missions';
|
||||
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
|
||||
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
MissionProgress,
|
||||
} from './ExpandableMissionCard';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsPanelProps {
|
||||
/** The daily missions to display */
|
||||
missions: DailyMission[];
|
||||
/** Callback when claiming a mission reward */
|
||||
onClaimReward: (missionId: string) => void;
|
||||
/** Callback when rerolling a mission */
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
/** Total coins earned today */
|
||||
todayCoins: number;
|
||||
/** Whether claiming is disabled (e.g., during another operation) */
|
||||
disabled?: boolean;
|
||||
/** Whether the bonus mission is available */
|
||||
bonusAvailable?: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed?: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward?: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
noMissionsAvailable?: boolean;
|
||||
/** Number of rerolls remaining today */
|
||||
rerollsRemaining?: number;
|
||||
/** Whether a reroll is currently in progress */
|
||||
isRerolling?: boolean;
|
||||
}
|
||||
|
||||
// ─── Mission Item ─────────────────────────────────────────────────────────────
|
||||
// ─── Daily Mission Icon Mapping ───────────────────────────────────────────────
|
||||
|
||||
interface MissionItemProps {
|
||||
mission: DailyMission;
|
||||
onClaim: () => void;
|
||||
onReroll?: () => void;
|
||||
disabled?: boolean;
|
||||
canReroll?: boolean;
|
||||
isRerolling?: boolean;
|
||||
function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
|
||||
const cls = 'size-5';
|
||||
switch (action) {
|
||||
case 'interact':
|
||||
return <Heart className={cls} />;
|
||||
case 'feed':
|
||||
return <Utensils className={cls} />;
|
||||
case 'clean':
|
||||
return <Droplets className={cls} />;
|
||||
case 'sleep':
|
||||
return <Moon className={cls} />;
|
||||
case 'take_photo':
|
||||
return <Camera className={cls} />;
|
||||
case 'sing':
|
||||
return <Mic className={cls} />;
|
||||
case 'play_music':
|
||||
return <Music className={cls} />;
|
||||
case 'medicine':
|
||||
return <Pill className={cls} />;
|
||||
default:
|
||||
return <CircleDot className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MissionItem({ mission, onClaim, onReroll, disabled, canReroll = false, isRerolling = false }: MissionItemProps) {
|
||||
const progressPercent = (mission.currentCount / mission.requiredCount) * 100;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
|
||||
// Can only reroll if: not completed, not claimed, has reroll callback, and has rerolls remaining
|
||||
const showRerollButton = onReroll && !mission.completed && !mission.claimed && canReroll;
|
||||
// ─── Bonus Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border transition-colors overflow-hidden',
|
||||
mission.claimed
|
||||
? 'bg-primary/5 border-primary/20'
|
||||
: mission.completed
|
||||
? 'bg-green-500/5 border-green-500/30'
|
||||
: 'bg-card border-border'
|
||||
)}
|
||||
>
|
||||
{/* Top right area: Claimed badge OR Reroll button */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{mission.claimed ? (
|
||||
<div className="flex items-center gap-1 text-xs text-primary font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
) : showRerollButton ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={onReroll}
|
||||
disabled={disabled || isRerolling}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", isRerolling && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Replace this mission</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className="pr-14 sm:pr-16">
|
||||
<h4 className="font-medium text-sm break-words">{mission.title}</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 break-words">
|
||||
{mission.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{mission.currentCount} / {mission.requiredCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400 whitespace-nowrap">
|
||||
<Coins className="size-3 shrink-0" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progressPercent}
|
||||
className={cn(
|
||||
'h-2',
|
||||
mission.completed && '[&>div]:bg-green-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Gift className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim {formatCompactNumber(mission.reward)} Coins</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission Item ───────────────────────────────────────────────────────
|
||||
|
||||
interface BonusMissionItemProps {
|
||||
interface BonusCardProps {
|
||||
isAvailable: boolean;
|
||||
isClaimed: boolean;
|
||||
reward: number;
|
||||
onClaim: () => void;
|
||||
disabled?: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function BonusMissionItem({ isAvailable, isClaimed, reward, onClaim, disabled }: BonusMissionItemProps) {
|
||||
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
|
||||
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative p-3 sm:p-4 rounded-lg border-2 transition-colors overflow-hidden',
|
||||
isClaimed
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: isAvailable
|
||||
? 'bg-gradient-to-br from-amber-500/10 to-orange-500/10 border-amber-500/40 animate-pulse'
|
||||
: 'bg-muted/30 border-dashed border-muted-foreground/20'
|
||||
)}
|
||||
<ExpandableMissionCard
|
||||
id="bonus"
|
||||
category="daily"
|
||||
icon={<Trophy className="size-5" />}
|
||||
title="Daily Champion"
|
||||
completed={isClaimed}
|
||||
progress={progress}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
{/* Claimed badge */}
|
||||
{isClaimed && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
|
||||
<Check className="size-3" />
|
||||
Claimed
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MissionDescription>
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
: 'Complete all missions to unlock this bonus'}
|
||||
</MissionDescription>
|
||||
|
||||
{/* Mission content */}
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{/* Title and description */}
|
||||
<div className={cn("pr-14 sm:pr-16", !isAvailable && !isClaimed && "opacity-50")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className={cn(
|
||||
"size-4 shrink-0",
|
||||
isClaimed
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: isAvailable
|
||||
? "text-amber-500"
|
||||
: "text-muted-foreground"
|
||||
)} />
|
||||
<h4 className="font-medium text-sm">Daily Champion</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
: 'Complete all missions above to unlock this bonus'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reward display */}
|
||||
<div className="flex items-center justify-between text-xs gap-2">
|
||||
<span className={cn(
|
||||
"text-muted-foreground",
|
||||
!isAvailable && !isClaimed && "opacity-50"
|
||||
)}>
|
||||
Bonus Reward
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 font-medium",
|
||||
isClaimed || isAvailable
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
<Coins className="size-3 shrink-0" />
|
||||
+{formatCompactNumber(reward)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
<Trophy className="size-4 mr-2 shrink-0" />
|
||||
<span className="truncate">Claim Bonus {formatCompactNumber(reward)} Coins!</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
+{formatCompactNumber(reward)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
|
||||
>
|
||||
<Trophy className="size-3.5 mr-1.5" />
|
||||
Claim Bonus {formatCompactNumber(reward)} Coins
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── No Missions Available State ──────────────────────────────────────────────
|
||||
// ─── Empty / Done States ──────────────────────────────────────────────────────
|
||||
|
||||
function NoMissionsState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Egg className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">Hatch Your Blobbi First</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Daily missions will be available once you have
|
||||
<br />
|
||||
a hatched Blobbi to interact with!
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Egg className="size-5 text-muted-foreground/50" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Hatch your Blobbi first</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Daily missions unlock after hatching
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── All Claimed State ────────────────────────────────────────────────────────
|
||||
|
||||
interface AllClaimedStateProps {
|
||||
todayCoins: number;
|
||||
}
|
||||
|
||||
function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
|
||||
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div className="size-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-sm">All Done for Today!</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You earned <span className="font-medium text-amber-600 dark:text-amber-400">{formatCompactNumber(todayCoins)} coins</span> today.
|
||||
<br />
|
||||
Come back tomorrow for new missions!
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Sparkles className="size-5 text-primary/60" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">All done for today</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Earned{' '}
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{formatCompactNumber(todayCoins)} coins
|
||||
</span>{' '}
|
||||
— come back tomorrow!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,20 +167,17 @@ function AllClaimedState({ todayCoins }: AllClaimedStateProps) {
|
||||
|
||||
// ─── Reroll Counter ───────────────────────────────────────────────────────────
|
||||
|
||||
interface RerollCounterProps {
|
||||
remaining: number;
|
||||
}
|
||||
function RerollCounter({ remaining }: { remaining: number }) {
|
||||
const text =
|
||||
remaining === 0
|
||||
? 'No rerolls left'
|
||||
: remaining === 1
|
||||
? '1 reroll left'
|
||||
: `${remaining} rerolls left`;
|
||||
|
||||
function RerollCounter({ remaining }: RerollCounterProps) {
|
||||
const text = remaining === 0
|
||||
? 'No rerolls left'
|
||||
: remaining === 1
|
||||
? '1 reroll left'
|
||||
: `${remaining} rerolls left`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
||||
<RefreshCw className="size-3" />
|
||||
<div className="flex items-center justify-end gap-1 text-[11px] text-muted-foreground col-span-full">
|
||||
<RefreshCw className="size-2.5" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -322,48 +198,121 @@ export function DailyMissionsPanel({
|
||||
rerollsRemaining = 0,
|
||||
isRerolling = false,
|
||||
}: DailyMissionsPanelProps) {
|
||||
// Show empty state if user has no eligible missions (e.g., only eggs)
|
||||
if (noMissionsAvailable) {
|
||||
return <NoMissionsState />;
|
||||
}
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (noMissionsAvailable) return <NoMissionsState />;
|
||||
|
||||
const allRegularClaimed = missions.every((m) => m.claimed);
|
||||
const allDone = allRegularClaimed && bonusClaimed;
|
||||
|
||||
// Show "all done" state only when everything including bonus is claimed
|
||||
if (allDone) {
|
||||
return <AllClaimedState todayCoins={todayCoins} />;
|
||||
}
|
||||
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Reroll counter - only show if reroll functionality is available */}
|
||||
{onRerollMission && (
|
||||
<RerollCounter remaining={rerollsRemaining} />
|
||||
)}
|
||||
|
||||
{/* Regular missions */}
|
||||
{missions.map((mission) => (
|
||||
<MissionItem
|
||||
key={mission.id}
|
||||
mission={mission}
|
||||
onClaim={() => onClaimReward(mission.id)}
|
||||
onReroll={onRerollMission ? () => onRerollMission(mission.id) : undefined}
|
||||
disabled={disabled}
|
||||
canReroll={canReroll}
|
||||
isRerolling={isRerolling}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bonus mission - always visible */}
|
||||
<BonusMissionItem
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{/* Reroll counter */}
|
||||
{onRerollMission && <RerollCounter remaining={rerollsRemaining} />}
|
||||
|
||||
{/* Regular mission cards */}
|
||||
{missions.map((mission) => {
|
||||
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
key={mission.id}
|
||||
id={mission.id}
|
||||
category="daily"
|
||||
icon={<DailyMissionIcon action={mission.action} />}
|
||||
title={mission.title}
|
||||
completed={mission.claimed}
|
||||
progress={Math.min(progress, 1)}
|
||||
isExpanded={expandedId === mission.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{/* Description */}
|
||||
<MissionDescription>{mission.description}</MissionDescription>
|
||||
|
||||
{/* Progress */}
|
||||
{!mission.claimed && (
|
||||
<MissionProgress
|
||||
current={mission.currentCount}
|
||||
required={mission.requiredCount}
|
||||
completed={mission.completed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reward + reroll row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
</span>
|
||||
|
||||
{showReroll && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRerollMission(mission.id);
|
||||
}}
|
||||
disabled={disabled || isRerolling}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn('size-3', isRerolling && 'animate-spin')} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Replace mission</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{mission.claimed && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
|
||||
<Check className="size-2.5" />
|
||||
Done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClaimReward(mission.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
|
||||
>
|
||||
<Gift className="size-3.5 mr-1.5" />
|
||||
Claim {formatCompactNumber(mission.reward)} Coins
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bonus card */}
|
||||
<BonusCard
|
||||
isAvailable={bonusAvailable}
|
||||
isClaimed={bonusClaimed}
|
||||
reward={bonusReward}
|
||||
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
|
||||
disabled={disabled}
|
||||
isExpanded={expandedId === 'bonus'}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// src/blobbi/actions/components/ExpandableMissionCard.tsx
|
||||
|
||||
/**
|
||||
* Expandable mission card for the quest-board grid.
|
||||
*
|
||||
* Collapsed: compact square-ish card showing icon, title, and a tiny
|
||||
* progress ring / checkmark.
|
||||
* Expanded: full-width row that reveals description, progress bar,
|
||||
* action link, claim button, dynamic hints, etc.
|
||||
*
|
||||
* Only one card is expanded at a time per section (controlled by parent).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MissionCategory = 'daily' | 'hatch' | 'evolve';
|
||||
|
||||
export interface ExpandableMissionCardProps {
|
||||
/** Unique id used to track which card is expanded */
|
||||
id: string;
|
||||
/** Mission category for visual styling */
|
||||
category: MissionCategory;
|
||||
/** Icon rendered in the compact card (ReactNode — usually a lucide icon or emoji span) */
|
||||
icon: ReactNode;
|
||||
/** Short title */
|
||||
title: string;
|
||||
/** Whether the mission is complete */
|
||||
completed: boolean;
|
||||
/** Progress fraction 0-1 (used for the tiny ring in compact mode) */
|
||||
progress: number;
|
||||
/** Whether this card is currently expanded */
|
||||
isExpanded: boolean;
|
||||
/** Parent calls this to toggle expansion */
|
||||
onToggle: (id: string) => void;
|
||||
/** Content rendered only when expanded */
|
||||
children: ReactNode;
|
||||
/** Optional extra className on the outer wrapper */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Tiny Progress Ring ───────────────────────────────────────────────────────
|
||||
|
||||
function ProgressRing({ progress, completed, category }: { progress: number; completed: boolean; category: MissionCategory }) {
|
||||
const size = 28;
|
||||
const stroke = 2.5;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
if (completed) {
|
||||
return (
|
||||
<div className="size-7 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ringColor =
|
||||
category === 'hatch'
|
||||
? 'text-sky-500'
|
||||
: category === 'evolve'
|
||||
? 'text-violet-500'
|
||||
: 'text-amber-500';
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={cn('shrink-0 -rotate-90', ringColor)}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
opacity={0.15}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Accent colors per category ───────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_STYLES: Record<MissionCategory, { bg: string; expandedBg: string; border: string }> = {
|
||||
daily: {
|
||||
bg: 'bg-amber-500/[0.06] hover:bg-amber-500/10',
|
||||
expandedBg: 'bg-amber-500/[0.06]',
|
||||
border: 'ring-amber-500/20',
|
||||
},
|
||||
hatch: {
|
||||
bg: 'bg-sky-500/[0.06] hover:bg-sky-500/10',
|
||||
expandedBg: 'bg-sky-500/[0.06]',
|
||||
border: 'ring-sky-500/20',
|
||||
},
|
||||
evolve: {
|
||||
bg: 'bg-violet-500/[0.06] hover:bg-violet-500/10',
|
||||
expandedBg: 'bg-violet-500/[0.06]',
|
||||
border: 'ring-violet-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ExpandableMissionCard({
|
||||
id,
|
||||
category,
|
||||
icon,
|
||||
title,
|
||||
completed,
|
||||
progress,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
className,
|
||||
}: ExpandableMissionCardProps) {
|
||||
const styles = CATEGORY_STYLES[category];
|
||||
|
||||
// ── Collapsed card ──
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-xl p-3 transition-all text-center cursor-pointer select-none',
|
||||
'ring-1 ring-transparent',
|
||||
completed ? 'bg-emerald-500/[0.06] hover:bg-emerald-500/10' : styles.bg,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="text-lg leading-none">{icon}</div>
|
||||
|
||||
{/* Title — 2 lines max */}
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium leading-tight line-clamp-2 min-h-[2lh]',
|
||||
completed && 'text-emerald-600 dark:text-emerald-400',
|
||||
)}>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{/* Progress ring / check */}
|
||||
<ProgressRing progress={progress} completed={completed} category={category} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Expanded card (spans full row) ──
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-full rounded-xl ring-1 transition-all overflow-hidden',
|
||||
completed ? 'bg-emerald-500/[0.06] ring-emerald-500/20' : cn(styles.expandedBg, styles.border),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Compact header — click to collapse */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left cursor-pointer select-none"
|
||||
>
|
||||
<div className="text-lg leading-none shrink-0">{icon}</div>
|
||||
<span className={cn(
|
||||
'text-sm font-medium flex-1 min-w-0',
|
||||
completed && 'text-emerald-600 dark:text-emerald-400',
|
||||
)}>
|
||||
{title}
|
||||
</span>
|
||||
<ProgressRing progress={progress} completed={completed} category={category} />
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
<div className="px-3 pb-3 pt-0 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared detail sub-components ─────────────────────────────────────────────
|
||||
|
||||
/** Description text */
|
||||
export function MissionDescription({ children }: { children: ReactNode }) {
|
||||
return <p className="text-xs text-muted-foreground leading-snug">{children}</p>;
|
||||
}
|
||||
|
||||
/** Progress bar with fraction label */
|
||||
export function MissionProgress({ current, required, completed }: { current: number; required: number; completed: boolean }) {
|
||||
const pct = required > 0 ? Math.round((current / required) * 100) : 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground mb-1">
|
||||
<span className="tabular-nums">{current} / {required}</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<Progress value={pct} className={cn('h-1.5', completed && '[&>div]:bg-emerald-500')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline action link (navigate, external, modal) */
|
||||
export function MissionAction({
|
||||
label,
|
||||
type,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
type: 'navigate' | 'external_link' | 'open_modal';
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{label}
|
||||
{type === 'external_link' ? (
|
||||
<ExternalLink className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dynamic / live task hint */
|
||||
export function DynamicHint({ current, required }: { current: number; required: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-amber-600/80 dark:text-amber-400/80">
|
||||
<AlertCircle className="size-3 shrink-0" />
|
||||
<span>Lowest stat: {current}% (need {required}%+)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
// src/blobbi/actions/components/TasksPanel.tsx
|
||||
|
||||
/**
|
||||
* Generic UI component for displaying task progress.
|
||||
* Shows a list of tasks with progress indicators and action buttons.
|
||||
* Used for both hatch and evolve tasks.
|
||||
* Card-grid presentation for hatch / evolve tasks.
|
||||
*
|
||||
* Each task is a compact card in a 2-column grid.
|
||||
* Tapping a card expands it inline (full row) to reveal details.
|
||||
* Only one card is expanded at a time.
|
||||
*/
|
||||
|
||||
import { ExternalLink, Check, Loader2, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Palette,
|
||||
Droplets,
|
||||
MessageSquare,
|
||||
Heart,
|
||||
UserPen,
|
||||
Activity,
|
||||
Loader2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '../hooks/useHatchTasks';
|
||||
import type { MissionCategory } from './ExpandableMissionCard';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
MissionProgress,
|
||||
MissionAction,
|
||||
DynamicHint,
|
||||
} from './ExpandableMissionCard';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,149 +40,38 @@ interface TasksPanelProps {
|
||||
tasks: HatchTask[];
|
||||
allCompleted: boolean;
|
||||
isLoading: boolean;
|
||||
/** Called when user clicks "Create Post" action */
|
||||
onOpenPostModal: () => void;
|
||||
/** Called when all tasks are complete and user clicks the complete button */
|
||||
onComplete: () => void;
|
||||
/** Whether completion is in progress */
|
||||
isCompleting?: boolean;
|
||||
/** Emoji to show in header */
|
||||
emoji: string;
|
||||
/** Title for the tasks panel */
|
||||
title: string;
|
||||
/** Description for the tasks panel */
|
||||
description: string;
|
||||
/** Label for the complete button */
|
||||
completeLabel: string;
|
||||
/** Label while completing */
|
||||
completingLabel: string;
|
||||
/** Emoji for complete button */
|
||||
completeEmoji: string;
|
||||
/** Mission category for styling the cards */
|
||||
category?: MissionCategory;
|
||||
}
|
||||
|
||||
// ─── Task Row Component ───────────────────────────────────────────────────────
|
||||
// ─── Task Icon Mapping ────────────────────────────────────────────────────────
|
||||
|
||||
interface TaskRowProps {
|
||||
task: HatchTask;
|
||||
onOpenPostModal: () => void;
|
||||
}
|
||||
/** Map task ids to lucide icons. Falls back to a generic icon. */
|
||||
function TaskIcon({ taskId }: { taskId: string }) {
|
||||
const iconClass = 'size-5';
|
||||
|
||||
function TaskRow({ task, onOpenPostModal }: TaskRowProps) {
|
||||
const navigate = useNavigate();
|
||||
const isDynamic = task.type === 'dynamic';
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') {
|
||||
onOpenPostModal();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = task.required > 1
|
||||
? Math.round((task.current / task.required) * 100)
|
||||
: task.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border transition-all overflow-hidden",
|
||||
task.completed
|
||||
? "bg-emerald-500/5 border-emerald-500/20"
|
||||
: isDynamic
|
||||
? "bg-amber-500/5 border-amber-500/20"
|
||||
: "bg-card/60 border-border hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Status + Task info */}
|
||||
<div className="flex items-start sm:items-center gap-3 sm:contents">
|
||||
{/* Status indicator */}
|
||||
<div className={cn(
|
||||
"size-8 sm:size-10 rounded-full flex items-center justify-center shrink-0",
|
||||
task.completed
|
||||
? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
|
||||
: isDynamic
|
||||
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{task.completed ? (
|
||||
<Check className="size-4 sm:size-5" />
|
||||
) : isDynamic ? (
|
||||
<AlertCircle className="size-4 sm:size-5" />
|
||||
) : task.required > 1 ? (
|
||||
<span className="text-xs sm:text-sm font-medium">{task.current}/{task.required}</span>
|
||||
) : (
|
||||
<span className="text-base sm:text-lg">○</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2 mb-0.5 sm:mb-1">
|
||||
<h4 className={cn(
|
||||
"font-medium text-sm sm:text-base break-words",
|
||||
task.completed && "text-emerald-600 dark:text-emerald-400",
|
||||
isDynamic && !task.completed && "text-amber-600 dark:text-amber-400"
|
||||
)}>
|
||||
{task.name}
|
||||
</h4>
|
||||
{task.completed && (
|
||||
<Badge variant="secondary" className="bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 text-xs shrink-0">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
{isDynamic && !task.completed && (
|
||||
<Badge variant="secondary" className="bg-amber-500/20 text-amber-700 dark:text-amber-300 text-xs shrink-0">
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground break-words">
|
||||
{task.description}
|
||||
</p>
|
||||
|
||||
{/* Progress bar for multi-step tasks (not for dynamic stat tasks) */}
|
||||
{task.required > 1 && !task.completed && !isDynamic && (
|
||||
<Progress value={progress} className="h-1.5 mt-2" />
|
||||
)}
|
||||
|
||||
{/* Dynamic task hint */}
|
||||
{isDynamic && !task.completed && (
|
||||
<p className="text-xs text-amber-600/70 dark:text-amber-400/70 mt-1">
|
||||
Lowest stat: {task.current}% (need {task.required}%+)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button - full width on mobile when present */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAction}
|
||||
className="shrink-0 gap-2 w-full sm:w-auto mt-1 sm:mt-0"
|
||||
>
|
||||
<span className="truncate">{task.actionLabel}</span>
|
||||
{task.action === 'external_link' ? (
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
switch (taskId) {
|
||||
case 'create_themes':
|
||||
return <Palette className={iconClass} />;
|
||||
case 'color_moments':
|
||||
return <Droplets className={iconClass} />;
|
||||
case 'create_posts':
|
||||
return <MessageSquare className={iconClass} />;
|
||||
case 'interactions':
|
||||
return <Heart className={iconClass} />;
|
||||
case 'edit_profile':
|
||||
return <UserPen className={iconClass} />;
|
||||
case 'maintain_stats':
|
||||
return <Activity className={iconClass} />;
|
||||
default:
|
||||
return <HelpCircle className={iconClass} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
@@ -178,86 +83,113 @@ export function TasksPanel({
|
||||
onOpenPostModal,
|
||||
onComplete,
|
||||
isCompleting = false,
|
||||
emoji,
|
||||
title,
|
||||
description,
|
||||
completeLabel,
|
||||
completingLabel,
|
||||
completeEmoji,
|
||||
category = 'hatch',
|
||||
}: TasksPanelProps) {
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalTasks = tasks.length;
|
||||
const overallProgress = totalTasks > 0 ? Math.round((completedCount / totalTasks) * 100) : 0;
|
||||
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent overflow-hidden">
|
||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<span className="text-xl sm:text-2xl shrink-0">{emoji}</span>
|
||||
<span className="break-words">{title}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm break-words">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm sm:text-base px-2 sm:px-3 py-0.5 sm:py-1 shrink-0">
|
||||
{completedCount}/{totalTasks}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mt-3 sm:mt-4">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm mb-2">
|
||||
<span className="text-muted-foreground">Overall progress</span>
|
||||
<span className="font-medium">{overallProgress}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-2" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 sm:space-y-3 px-3 sm:px-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tasks.map(task => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenPostModal={onOpenPostModal}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Complete button - only visible when all tasks complete */}
|
||||
{allCompleted && (
|
||||
<div className="pt-4 border-t border-border mt-4">
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isCompleting}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white"
|
||||
>
|
||||
{isCompleting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{completingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xl">{completeEmoji}</span>
|
||||
{completeLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-3">
|
||||
{/* Card grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{tasks.map((task) => {
|
||||
const isDynamic = task.type === 'dynamic';
|
||||
const progress =
|
||||
task.required > 0 ? task.current / task.required : task.completed ? 1 : 0;
|
||||
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
switch (task.action) {
|
||||
case 'navigate':
|
||||
navigate(task.actionTarget);
|
||||
break;
|
||||
case 'external_link':
|
||||
openUrl(task.actionTarget);
|
||||
break;
|
||||
case 'open_modal':
|
||||
if (task.actionTarget === 'blobbi_post') onOpenPostModal();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
key={task.id}
|
||||
id={task.id}
|
||||
category={category}
|
||||
icon={<TaskIcon taskId={task.id} />}
|
||||
title={task.name}
|
||||
completed={task.completed}
|
||||
progress={Math.min(progress, 1)}
|
||||
isExpanded={expandedId === task.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
{/* Expanded content */}
|
||||
<MissionDescription>{task.description}</MissionDescription>
|
||||
|
||||
{/* Progress bar for multi-step tasks */}
|
||||
{task.required > 1 && !isDynamic && (
|
||||
<MissionProgress
|
||||
current={task.current}
|
||||
required={task.required}
|
||||
completed={task.completed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dynamic stat hint */}
|
||||
{isDynamic && !task.completed && (
|
||||
<DynamicHint current={task.current} required={task.required} />
|
||||
)}
|
||||
|
||||
{/* Action link */}
|
||||
{task.action && task.actionLabel && !task.completed && (
|
||||
<MissionAction
|
||||
label={task.actionLabel}
|
||||
type={task.action}
|
||||
onClick={handleAction}
|
||||
/>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA button when all tasks are done */}
|
||||
{allCompleted && (
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isCompleting}
|
||||
size="lg"
|
||||
className="w-full gap-2 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white shadow-sm"
|
||||
>
|
||||
{isCompleting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{completingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-lg">{completeEmoji}</span>
|
||||
{completeLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -24,7 +25,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
@@ -34,8 +40,6 @@ export interface UseBlobbiCareActivityParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
export interface CareActivityResult {
|
||||
@@ -59,8 +63,8 @@ export interface CareActivityResult {
|
||||
export function useBlobbiCareActivity({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiCareActivityParams) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -78,12 +82,24 @@ export function useBlobbiCareActivity({
|
||||
throw new Error('No companion available');
|
||||
}
|
||||
|
||||
// Fetch fresh companion from relays (read-modify-write pattern)
|
||||
const freshEvents = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [companion.d],
|
||||
}]);
|
||||
const freshCompanion = freshEvents
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map(e => parseBlobbiEvent(e))
|
||||
.find(Boolean) ?? companion;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Calculate what the streak update should be
|
||||
// Calculate what the streak update should be using fresh data
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
freshCompanion.careStreak,
|
||||
freshCompanion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
@@ -96,29 +112,29 @@ export function useBlobbiCareActivity({
|
||||
};
|
||||
}
|
||||
|
||||
// Get the tag updates
|
||||
const streakUpdates = getStreakTagUpdates(companion, now);
|
||||
// Get the tag updates using fresh data
|
||||
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
|
||||
|
||||
if (!streakUpdates) {
|
||||
// Shouldn't happen if wasUpdated is true, but handle gracefully
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
newStreak: freshCompanion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Build updated tags
|
||||
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
|
||||
// Build updated tags from fresh data
|
||||
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
|
||||
|
||||
// Publish the updated event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content,
|
||||
content: freshCompanion.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update local cache
|
||||
// Update local cache (optimistic — no invalidation needed)
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// Update session tracker
|
||||
@@ -128,9 +144,9 @@ export function useBlobbiCareActivity({
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CareActivity] Streak updated:', {
|
||||
action: result.action,
|
||||
previousStreak: companion.careStreak,
|
||||
previousStreak: freshCompanion.careStreak,
|
||||
newStreak: result.newStreak,
|
||||
lastDay: companion.careStreakLastDay,
|
||||
lastDay: freshCompanion.careStreakLastDay,
|
||||
newDay: result.newLastDay,
|
||||
});
|
||||
}
|
||||
@@ -141,11 +157,6 @@ export function useBlobbiCareActivity({
|
||||
action: result.action,
|
||||
};
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.wasUpdated) {
|
||||
invalidateCompanion();
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[CareActivity] Failed to update streak:', error);
|
||||
},
|
||||
|
||||
@@ -69,10 +69,6 @@ export interface UseBlobbiDirectActionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration happened) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,8 +88,6 @@ export function useBlobbiDirectAction({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiDirectActionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -189,12 +183,6 @@ export function useBlobbiDirectAction({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
|
||||
@@ -66,10 +66,6 @@ export interface UseStartIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +108,6 @@ export function useStartIncubation({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
@@ -269,12 +263,6 @@ export function useStartIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -329,10 +317,6 @@ export interface UseStopIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,8 +347,6 @@ export function useStopIncubation({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -435,12 +417,6 @@ export function useStopIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -480,10 +456,6 @@ export interface UseStartEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,8 +483,6 @@ export function useStartEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -585,12 +555,6 @@ export function useStartEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -631,10 +595,6 @@ export interface UseStopEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -665,8 +625,6 @@ export function useStopEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -736,12 +694,6 @@ export function useStopEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -784,10 +736,6 @@ export interface UseSyncTaskCompletionsParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -827,8 +775,6 @@ export function useSyncTaskCompletions({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseSyncTaskCompletionsParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -923,11 +869,6 @@ export function useSyncTaskCompletions({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Published successfully:', tagsToAdd);
|
||||
|
||||
@@ -69,10 +69,6 @@ export interface UseBlobbiStageTransitionParams {
|
||||
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +109,6 @@ export function useBlobbiHatch({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -220,12 +214,6 @@ export function useBlobbiHatch({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'egg',
|
||||
@@ -268,8 +256,6 @@ export function useBlobbiEvolve({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -376,12 +362,6 @@ export function useBlobbiEvolve({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'baby',
|
||||
|
||||
@@ -80,10 +80,6 @@ export interface UseBlobbiUseInventoryItemParams {
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Update profile event in local cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
// Import NostrEvent type
|
||||
@@ -107,8 +103,6 @@ export function useBlobbiUseInventoryItem({
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -145,15 +139,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -407,27 +392,29 @@ export function useBlobbiUseInventoryItem({
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
|
||||
// instead of profile.storage/profile.allTags to avoid restoring
|
||||
// stale/legacy values after migration
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
// Only decrement storage if the item actually exists in inventory.
|
||||
// Items are free to use regardless of inventory state.
|
||||
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
|
||||
if (hasItemInStorage) {
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
invalidateProfile();
|
||||
// No query invalidation needed — the optimistic updates above keep the
|
||||
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
|
||||
// before every mutation (read-modify-write pattern).
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for wall edit events */
|
||||
export const KIND_WALL_EDIT = 16769;
|
||||
/** Kind for custom profile tabs event */
|
||||
export const KIND_PROFILE_TABS = 16769;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
@@ -117,7 +117,7 @@ export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolea
|
||||
* 2. Create 3 Color Moments (kind 3367)
|
||||
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
|
||||
* 4. Interact 21 times (tracked via companion.tasks cache)
|
||||
* 5. Edit Wall once (kind 16769)
|
||||
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 6. Maintain All Stats >= 80
|
||||
@@ -165,14 +165,14 @@ export function useEvolveTasks(
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Only need 1 valid evolve post
|
||||
},
|
||||
// Wall edits after start
|
||||
// Custom profile tabs after start
|
||||
{
|
||||
kinds: [KIND_WALL_EDIT],
|
||||
kinds: [KIND_PROFILE_TABS],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check)
|
||||
// Profile metadata after start (for Blobbi shape check + profile edit mission)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
@@ -197,8 +197,8 @@ export function useEvolveTasks(
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const wallEditEvents = events.filter(e =>
|
||||
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
|
||||
const profileTabsEvents = events.filter(e =>
|
||||
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Get latest profile after start
|
||||
@@ -211,7 +211,7 @@ export function useEvolveTasks(
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
wallEditEvents,
|
||||
profileTabsEvents,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
@@ -287,20 +287,21 @@ export function useEvolveTasks(
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Wall once (PERSISTENT)
|
||||
const wallEditCount = data?.wallEditEvents?.length ?? 0;
|
||||
const hasWallEdit = wallEditCount >= 1;
|
||||
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
|
||||
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
|
||||
const hasMetadataEdit = !!data?.profileAfter;
|
||||
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
|
||||
tasks.push({
|
||||
id: 'edit_wall',
|
||||
name: 'Edit Your Wall',
|
||||
description: 'Customize your profile wall',
|
||||
current: hasWallEdit ? 1 : 0,
|
||||
id: 'edit_profile',
|
||||
name: 'Edit Your Profile',
|
||||
description: 'Update your profile info or customize your profile tabs',
|
||||
current: hasProfileEdit ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasWallEdit,
|
||||
completed: hasProfileEdit,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Wall',
|
||||
actionLabel: 'Edit Profile',
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
|
||||
@@ -34,10 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1;
|
||||
export const HATCH_REQUIRED_INTERACTIONS = 7;
|
||||
|
||||
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
|
||||
|
||||
/** Prefix text for Blobbi hatch post */
|
||||
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
|
||||
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
|
||||
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
@@ -110,16 +110,28 @@ export interface HatchTasksResult {
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the required phrase for a hatch post.
|
||||
* Format: "Posting to hatch {CapitalizedName} #blobbi"
|
||||
*/
|
||||
export function buildHatchPhrase(blobbiName: string): string {
|
||||
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi hatch post.
|
||||
* Must contain the required prefix and all required hashtags including the Blobbi name.
|
||||
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
|
||||
* The user may add extra text before or after it.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
* @param blobbiName - The Blobbi's name
|
||||
*/
|
||||
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with prefix
|
||||
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
|
||||
const phrase = buildHatchPhrase(blobbiName);
|
||||
|
||||
// The phrase must appear somewhere in the content
|
||||
if (!event.content.includes(phrase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -128,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
// All required hashtags must be present as t tags
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
return hasRequiredHashtags;
|
||||
}
|
||||
|
||||
// Legacy function name for backwards compatibility
|
||||
|
||||
@@ -55,8 +55,6 @@ export {
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
@@ -70,7 +68,7 @@ export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_WALL_EDIT,
|
||||
KIND_PROFILE_TABS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
|
||||
@@ -17,12 +17,14 @@ import { useMemo, memo, type RefObject } from 'react';
|
||||
|
||||
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
|
||||
|
||||
@@ -248,7 +250,14 @@ export function BlobbiCompanionVisual({
|
||||
)}
|
||||
style={{ transformOrigin: 'center bottom' }}
|
||||
>
|
||||
{(companion.stage === 'baby' || companion.stage === 'adult') && (
|
||||
{companion.stage === 'egg' ? (
|
||||
<BlobbiStageVisual
|
||||
companion={companion as unknown as BlobbiCompanion}
|
||||
size="sm"
|
||||
animated={false}
|
||||
className="size-full"
|
||||
/>
|
||||
) : (
|
||||
<MemoizedBlobbiVisual
|
||||
stage={companion.stage}
|
||||
blobbi={blobbi}
|
||||
|
||||
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging - let gravity take over.
|
||||
* End dragging - hold position where dropped.
|
||||
*/
|
||||
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
|
||||
return {
|
||||
...motion,
|
||||
isDragging: false,
|
||||
// If already at or below ground, snap to ground
|
||||
isGrounded: motion.position.y >= groundY,
|
||||
// Always treat as grounded so companion holds position where dropped
|
||||
isGrounded: true,
|
||||
position: {
|
||||
...motion.position,
|
||||
y: motion.position.y >= groundY ? groundY : motion.position.y,
|
||||
// Clamp to ground if below it
|
||||
y: Math.min(motion.position.y, groundY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
// Track if first entry has completed (for position initialization)
|
||||
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
|
||||
|
||||
// Track viewport size
|
||||
// Track viewport size — listen to both window resize and visualViewport
|
||||
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setViewport({
|
||||
@@ -114,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate bounds and positions
|
||||
|
||||
@@ -80,9 +80,6 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
|
||||
if (!blobbi) return null;
|
||||
|
||||
// Only baby and adult can be companions
|
||||
if (blobbi.stage === 'egg') return null;
|
||||
|
||||
// Use projected stats if available, otherwise fall back to base stats
|
||||
const stats = projectedState?.stats ?? blobbi.stats;
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
* idle -> rising -> inspecting -> entering -> complete
|
||||
*
|
||||
* Route change behavior:
|
||||
* - Cancels current entry immediately
|
||||
* - Waits 1 second
|
||||
* - Restarts entry for the new page
|
||||
* - Companion keeps its current position (no re-entry animation)
|
||||
* - Only initial mount and companion changes trigger entry animations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -310,20 +309,11 @@ export function useBlobbiEntryAnimation({
|
||||
// Random entry type for new companion (fall or rise)
|
||||
const entryType: EntryType = Math.random() < 0.5 ? 'fall' : 'rise';
|
||||
startEntry(entryType);
|
||||
} else if (routeChanged && companionId) {
|
||||
// Route changed - determine direction for new route
|
||||
const entryType = getEntryDirection(previousPath, pathname, sidebarOrder);
|
||||
|
||||
// Immediately hide Blobbi and cancel current entry
|
||||
cancelEntry();
|
||||
setIsHiddenForTransition(true);
|
||||
|
||||
// Wait 1 second, then start the new entry animation
|
||||
routeChangeTimeoutRef.current = setTimeout(() => {
|
||||
startEntry(entryType);
|
||||
}, entryConfig.routeChangeRestartDelay);
|
||||
} else if (routeChanged) {
|
||||
// Route changed - companion keeps its position, no re-entry animation.
|
||||
// Just update the ref so future changes compare against the new path.
|
||||
}
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
|
||||
|
||||
/**
|
||||
* Animation loop for FALL entry.
|
||||
|
||||
@@ -69,14 +69,13 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
/** Optimistically update the TanStack cache so the companion reacts immediately. */
|
||||
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
return;
|
||||
}
|
||||
if (!parsed) return;
|
||||
|
||||
// Optimistically update ALL blobbi-collection queries for this user.
|
||||
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
|
||||
// partial matching to find all entries regardless of dList shape.
|
||||
// No invalidation needed — we fetched fresh from relays before mutating,
|
||||
// so the optimistic update is the correct state.
|
||||
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
|
||||
const matchingQueries = queryClient.getQueriesData<CollectionData>({
|
||||
queryKey: ['blobbi-collection', pubkey],
|
||||
@@ -90,9 +89,6 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// Also invalidate for background refetch to ensure eventual consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
}, [queryClient]);
|
||||
|
||||
const toggleSleep = useCallback(async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -8,12 +9,18 @@ import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
BLOBBONAUT_PROFILE_KINDS,
|
||||
getBlobbonautQueryDValues,
|
||||
buildMigrationTags,
|
||||
generatePetId10,
|
||||
getCanonicalBlobbiD,
|
||||
isValidBlobbiEvent,
|
||||
isValidBlobbonautEvent,
|
||||
isLegacyBlobbonautKind,
|
||||
migratePetInHas,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbiEvent,
|
||||
parseBlobbonautEvent,
|
||||
parseStorageTags,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
@@ -52,10 +59,6 @@ export interface EnsureCanonicalOptions {
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update localStorage selection if it was pointing to legacy d */
|
||||
updateStoredSelectedD?: (newD: string) => void;
|
||||
/** Callback to invalidate companion query */
|
||||
invalidateCompanion?: () => void;
|
||||
/** Callback to invalidate profile query */
|
||||
invalidateProfile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +114,7 @@ export interface EnsureCanonicalResult {
|
||||
* ```
|
||||
*/
|
||||
export function useBlobbiMigration() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -134,8 +138,6 @@ export function useBlobbiMigration() {
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
} = options;
|
||||
|
||||
if (!user?.pubkey) {
|
||||
@@ -190,7 +192,8 @@ export function useBlobbiMigration() {
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
// Update query caches
|
||||
// Update query caches (optimistic — no invalidation needed since we
|
||||
// fetch fresh from relays before every mutation)
|
||||
updateProfileEvent(profileEvent);
|
||||
updateCompanionEvent(canonicalEvent);
|
||||
|
||||
@@ -200,10 +203,6 @@ export function useBlobbiMigration() {
|
||||
updateStoredSelectedD(canonicalD);
|
||||
}
|
||||
|
||||
// Invalidate queries to refetch fresh data
|
||||
invalidateCompanion?.();
|
||||
invalidateProfile?.();
|
||||
|
||||
toast({
|
||||
title: 'Pet upgraded!',
|
||||
description: `${companion.name} has been migrated to the new format.`,
|
||||
@@ -237,29 +236,102 @@ export function useBlobbiMigration() {
|
||||
}
|
||||
}, [user?.pubkey, publishEvent]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest companion event directly from relays, bypassing cache.
|
||||
* This is the read step of the read-modify-write pattern.
|
||||
*/
|
||||
const fetchFreshCompanion = useCallback(async (
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<BlobbiCompanion | null> => {
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag],
|
||||
}]);
|
||||
|
||||
const validEvents = events
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest profile event directly from relays, bypassing cache.
|
||||
*/
|
||||
const fetchFreshProfile = useCallback(async (
|
||||
pubkey: string,
|
||||
): Promise<BlobbonautProfile | null> => {
|
||||
const dValues = getBlobbonautQueryDValues(pubkey);
|
||||
const events = await nostr.query([{
|
||||
kinds: [...BLOBBONAUT_PROFILE_KINDS],
|
||||
authors: [pubkey],
|
||||
'#d': dValues,
|
||||
}]);
|
||||
|
||||
const validEvents = events.filter(isValidBlobbonautEvent);
|
||||
if (validEvents.length === 0) return null;
|
||||
|
||||
// Prefer current kind over legacy
|
||||
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
|
||||
if (currentKindEvents.length > 0) {
|
||||
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
|
||||
if (legacyKindEvents.length > 0) {
|
||||
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Ensure a Blobbi is in canonical format before performing an action.
|
||||
*
|
||||
* CRITICAL: This fetches fresh data from relays (read-modify-write pattern)
|
||||
* instead of using potentially stale cache data. This prevents state resets
|
||||
* caused by publishing over a newer event with stale cached data.
|
||||
*
|
||||
* If the companion is legacy, it will be migrated first.
|
||||
* Returns the canonical companion to use for the action.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if Blobbi is legacy
|
||||
* 2. If legacy: migrate Blobbi
|
||||
* 3. Return the resolved canonical Blobbi
|
||||
* 1. Fetch fresh companion + profile from relays
|
||||
* 2. Check if Blobbi is legacy
|
||||
* 3. If legacy: migrate Blobbi
|
||||
* 4. Return the resolved canonical Blobbi with fresh data
|
||||
*
|
||||
* All interaction handlers should call this before publishing events.
|
||||
*/
|
||||
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<EnsureCanonicalResult | null> => {
|
||||
const { companion, profile } = options;
|
||||
if (!user?.pubkey) return null;
|
||||
|
||||
const { companion: cachedCompanion, profile: cachedProfile } = options;
|
||||
|
||||
// Fetch fresh data from relays (read step of read-modify-write)
|
||||
const [freshCompanion, freshProfile] = await Promise.all([
|
||||
fetchFreshCompanion(user.pubkey, cachedCompanion.d),
|
||||
fetchFreshProfile(user.pubkey),
|
||||
]);
|
||||
|
||||
// Use fresh data, falling back to cached only if relay fetch returned nothing
|
||||
const companion = freshCompanion ?? cachedCompanion;
|
||||
const profile = freshProfile ?? cachedProfile;
|
||||
|
||||
// Check if the companion needs migration
|
||||
if (companion.isLegacy) {
|
||||
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
|
||||
|
||||
const migrationResult = await migrateLegacyBlobbi(options);
|
||||
// Use fresh data in migration options
|
||||
const migrationOptions = { ...options, companion, profile };
|
||||
const migrationResult = await migrateLegacyBlobbi(migrationOptions);
|
||||
|
||||
if (!migrationResult) {
|
||||
// Migration failed, cannot proceed with action
|
||||
@@ -279,7 +351,7 @@ export function useBlobbiMigration() {
|
||||
};
|
||||
}
|
||||
|
||||
// Companion is already canonical, return profile as-is
|
||||
// Companion is already canonical, return fresh data
|
||||
return {
|
||||
wasMigrated: false,
|
||||
companion,
|
||||
@@ -288,7 +360,7 @@ export function useBlobbiMigration() {
|
||||
profileAllTags: profile.allTags,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [migrateLegacyBlobbi]);
|
||||
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
|
||||
|
||||
return {
|
||||
/** Migrate a legacy Blobbi to canonical format */
|
||||
|
||||
@@ -132,7 +132,10 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
|
||||
// Helper to invalidate and refetch after publishing
|
||||
// Helper to invalidate and refetch after publishing.
|
||||
// NOTE: In most mutation paths this is no longer needed — the read-modify-write
|
||||
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
|
||||
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
|
||||
const invalidate = useCallback(() => {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -141,36 +144,38 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update)
|
||||
// Update a single companion event in the query cache (optimistic update).
|
||||
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
|
||||
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
|
||||
// and companion layer cache stay in sync (they use different d-tag lists).
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
(prev) => {
|
||||
if (!prev) {
|
||||
return {
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
};
|
||||
}
|
||||
|
||||
// Update the specific companion in the record
|
||||
const newCompanionsByD = {
|
||||
...prev.companionsByD,
|
||||
[parsed.d]: parsed,
|
||||
};
|
||||
|
||||
// Rebuild companions array from the record
|
||||
const newCompanions = Object.values(newCompanionsByD);
|
||||
|
||||
return {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: newCompanions,
|
||||
};
|
||||
}
|
||||
);
|
||||
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
|
||||
const matchingQueries = queryClient.getQueriesData<CollectionData>({
|
||||
queryKey: ['blobbi-collection', user.pubkey],
|
||||
});
|
||||
|
||||
for (const [queryKey, data] of matchingQueries) {
|
||||
if (!data) continue;
|
||||
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
|
||||
queryClient.setQueryData<CollectionData>(queryKey, {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// If no existing queries matched (first load), set our own query key
|
||||
if (matchingQueries.length === 0) {
|
||||
queryClient.setQueryData<CollectionData>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
{
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Memoize return values for stability
|
||||
@@ -190,7 +195,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
isStale: query.isStale,
|
||||
/** Query error if any */
|
||||
error: query.error,
|
||||
/** Invalidate and refetch the collection */
|
||||
/** Invalidate and refetch the collection (use only when d-tag set changes, not after mutations) */
|
||||
invalidate,
|
||||
/** Optimistically update a single companion in the cache */
|
||||
updateCompanionEvent,
|
||||
|
||||
@@ -110,7 +110,7 @@ export function toEggGraphicVisualBlobbi(
|
||||
companion: BlobbiCompanion,
|
||||
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
|
||||
): EggVisualBlobbi {
|
||||
const { visualTraits, stage, allTags } = companion;
|
||||
const { visualTraits, stage, allTags = [] } = companion;
|
||||
|
||||
return {
|
||||
// Colors pass through directly (already CSS hex values)
|
||||
|
||||
@@ -976,7 +976,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
event,
|
||||
d,
|
||||
currentCompanion: getTagValue(tags, 'current_companion'),
|
||||
onboardingDone: parseBooleanTag(tags, 'onboarding_done', false),
|
||||
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|
||||
|| parseBooleanTag(tags, 'onboarding_done', false),
|
||||
name: getTagValue(tags, 'name'),
|
||||
has: getTagValues(tags, 'has'),
|
||||
coins: parseNumericTag(tags, 'coins') ?? 0,
|
||||
@@ -996,7 +997,7 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
|
||||
return [
|
||||
['d', getCanonicalBlobbonautD(pubkey)],
|
||||
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
|
||||
['onboarding_done', 'false'],
|
||||
['blobbi_onboarding_done', 'false'],
|
||||
['pettingLevel', '0'],
|
||||
];
|
||||
}
|
||||
@@ -1138,7 +1139,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
* These tags are controlled by the application and may be overwritten.
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage',
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
@@ -1365,17 +1366,44 @@ export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Build updated tags for normalizing a profile to include pettingLevel.
|
||||
* Preserves all existing tags and adds pettingLevel: 0 if missing.
|
||||
* Check if a profile uses the legacy `onboarding_done` tag instead of the
|
||||
* new `blobbi_onboarding_done` tag. Returns true if migration is needed.
|
||||
*/
|
||||
export function profileNeedsOnboardingTagMigration(profile: BlobbonautProfile): boolean {
|
||||
const hasNewTag = profile.allTags.some(([name]) => name === 'blobbi_onboarding_done');
|
||||
const hasOldTag = profile.allTags.some(([name]) => name === 'onboarding_done');
|
||||
// Needs migration if: has old tag but not the new one
|
||||
return !hasNewTag && hasOldTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build updated tags for normalizing a profile.
|
||||
* Handles:
|
||||
* - Adding pettingLevel: 0 if missing
|
||||
* - Migrating onboarding_done → blobbi_onboarding_done
|
||||
*
|
||||
* Preserves all existing tags except the ones being migrated.
|
||||
*/
|
||||
export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] {
|
||||
if (!profileNeedsPettingLevelNormalization(profile)) {
|
||||
return profile.allTags;
|
||||
let tags = profile.allTags;
|
||||
let changed = false;
|
||||
|
||||
// Normalize pettingLevel
|
||||
if (profileNeedsPettingLevelNormalization(profile)) {
|
||||
tags = updateBlobbonautTags(tags, { pettingLevel: '0' });
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return updateBlobbonautTags(profile.allTags, {
|
||||
pettingLevel: '0',
|
||||
});
|
||||
|
||||
// Migrate onboarding_done → blobbi_onboarding_done
|
||||
if (profileNeedsOnboardingTagMigration(profile)) {
|
||||
const oldValue = tags.find(([name]) => name === 'onboarding_done')?.[1] ?? 'false';
|
||||
// Remove old tag, add new tag
|
||||
tags = tags.filter(([name]) => name !== 'onboarding_done');
|
||||
tags = updateBlobbonautTags(tags, { blobbi_onboarding_done: oldValue });
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? tags : profile.allTags;
|
||||
}
|
||||
|
||||
// ─── Query Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -527,8 +527,10 @@ export function BlobbiDevEditor({
|
||||
onCheckedChange={setBreedingReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* IMPORTANT: This hook should only be used in development mode.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -24,8 +24,6 @@ interface UseBlobbiDevUpdateParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
interface DevUpdateResult {
|
||||
@@ -50,11 +48,9 @@ function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
export function useBlobbiDevUpdate({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiDevUpdateParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (updates: BlobbiDevUpdates): Promise<DevUpdateResult> => {
|
||||
@@ -169,12 +165,6 @@ export function useBlobbiDevUpdate({
|
||||
|
||||
// ─── Update Caches ───
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate collection queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
|
||||
return {
|
||||
previousStage: companion.stage,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { EggVisualBlobbi } from '../types/egg.types';
|
||||
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
|
||||
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
|
||||
@@ -25,6 +25,29 @@ export interface EggStatusEffects {
|
||||
happy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tour visual states that the egg can display.
|
||||
* Driven by the tour orchestration layer, not by EggGraphic itself.
|
||||
*
|
||||
* - idle: no tour effects
|
||||
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
|
||||
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
|
||||
* - crack_stage_1: crack expands (click 1)
|
||||
* - crack_stage_2: crack expands more (click 2)
|
||||
* - crack_stage_3: final crack (click 3)
|
||||
* - opening: shell splits open
|
||||
* - hatching: bright light + reveal
|
||||
*/
|
||||
export type EggTourVisualState =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'glowing_waiting_click'
|
||||
| 'crack_stage_1'
|
||||
| 'crack_stage_2'
|
||||
| 'crack_stage_3'
|
||||
| 'opening'
|
||||
| 'hatching';
|
||||
|
||||
interface EggGraphicProps {
|
||||
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
|
||||
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
|
||||
@@ -36,6 +59,10 @@ interface EggGraphicProps {
|
||||
forceInlineSvg?: boolean; // New prop to guarantee inline SVG
|
||||
/** Status effects for egg-stage visual feedback */
|
||||
statusEffects?: EggStatusEffects;
|
||||
/** Tour visual state - driven externally by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +141,8 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
warmth = 50,
|
||||
forceInlineSvg: _forceInlineSvg = false,
|
||||
statusEffects,
|
||||
tourVisualState = 'idle',
|
||||
onTourEggClick,
|
||||
}) => {
|
||||
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
|
||||
// Parent container controls actual rendered width/height via slot
|
||||
@@ -152,14 +181,62 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
const [isTapWiggling, setIsTapWiggling] = useState(false);
|
||||
|
||||
const handleEggClick = useCallback(() => {
|
||||
// Tour interactive steps: forward click to tour controller
|
||||
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
|
||||
setIsTapWiggling(true);
|
||||
onTourEggClick();
|
||||
return;
|
||||
}
|
||||
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
|
||||
setIsTapWiggling(true);
|
||||
}, [isTapWiggling, cracking]);
|
||||
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
|
||||
|
||||
const handleWiggleEnd = useCallback(() => {
|
||||
setIsTapWiggling(false);
|
||||
}, []);
|
||||
|
||||
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
|
||||
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
|
||||
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!shouldAutoWiggle) {
|
||||
if (autoWiggleTimerRef.current) {
|
||||
clearInterval(autoWiggleTimerRef.current);
|
||||
autoWiggleTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Trigger an immediate wiggle, then repeat every 2.5s
|
||||
setIsTapWiggling(true);
|
||||
autoWiggleTimerRef.current = setInterval(() => {
|
||||
setIsTapWiggling((prev) => {
|
||||
if (!prev) return true;
|
||||
return prev;
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
if (autoWiggleTimerRef.current) {
|
||||
clearInterval(autoWiggleTimerRef.current);
|
||||
autoWiggleTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shouldAutoWiggle]);
|
||||
|
||||
// Tour: whether the egg should show crack overlay
|
||||
// The crack stays visible during 'opening' so the shell fades out WITH its cracks intact.
|
||||
// Only 'idle' and 'hatching' (shell already gone) hide the crack.
|
||||
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
|
||||
|
||||
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
|
||||
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
|
||||
// Level 1: crack expands left/right with small branches (crack_stage_1)
|
||||
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
|
||||
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
|
||||
const tourCrackLevel = tourVisualState === 'crack_stage_1' ? 1
|
||||
: tourVisualState === 'crack_stage_2' ? 2
|
||||
: (tourVisualState === 'crack_stage_3' || tourVisualState === 'opening') ? 3
|
||||
: 0;
|
||||
|
||||
// Divine color constants
|
||||
const DIVINE_PRIMARY_GREEN = '#55C4A2';
|
||||
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
|
||||
@@ -440,18 +517,32 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Glow effect based on warmth - relative sizing */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full blur-xl transition-all duration-1000',
|
||||
animated && 'animate-pulse'
|
||||
)}
|
||||
style={{
|
||||
width: '120%',
|
||||
height: '120%',
|
||||
background: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const isGlowingTour = tourVisualState === 'glowing_waiting_click'
|
||||
|| tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2'
|
||||
|| tourVisualState === 'crack_stage_3';
|
||||
const isHatchLight = tourVisualState === 'opening' || tourVisualState === 'hatching';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full blur-xl transition-all duration-1000',
|
||||
animated && !isGlowingTour && !isHatchLight && 'animate-pulse',
|
||||
isGlowingTour && 'animate-egg-tour-glow',
|
||||
isHatchLight && 'animate-egg-tour-glow',
|
||||
)}
|
||||
style={{
|
||||
width: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
|
||||
height: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
|
||||
background: isHatchLight
|
||||
? `radial-gradient(circle, #fff 0%, ${glowColor} 40%, transparent 70%)`
|
||||
: isGlowingTour
|
||||
? `radial-gradient(circle, ${glowColor} 0%, ${glowColor}80 30%, transparent 70%)`
|
||||
: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Main egg shape - uses percentage-based sizing */}
|
||||
<div
|
||||
@@ -468,8 +559,12 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
!isTapWiggling && reaction === 'singing' && 'animate-egg-bounce',
|
||||
// Warmth effect only when animated AND warm
|
||||
animated && actualWarmth > 60 && 'animate-egg-warmth',
|
||||
// Cracking overrides other animations
|
||||
cracking && 'animate-egg-crack'
|
||||
// Cracking overrides other animations (legacy prop or tour crack stages)
|
||||
// During 'opening' the shell runs its own open animation, so suppress the shake
|
||||
(cracking || (tourCrackLevel >= 1 && tourVisualState !== 'opening')) && 'animate-egg-crack',
|
||||
// Opening/hatching: fade out the egg shell (crack overlay stays inside and fades with it)
|
||||
tourVisualState === 'opening' && 'animate-egg-tour-open',
|
||||
tourVisualState === 'hatching' && 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: '80%',
|
||||
@@ -480,7 +575,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
inset -0.5em -0.5em 1em ${shadow}33,
|
||||
inset 0.5em 0.5em 1em ${highlight}26
|
||||
`,
|
||||
filter: cracking ? 'brightness(1.1)' : 'brightness(1)',
|
||||
filter: (cracking || tourCrackLevel >= 1) ? 'brightness(1.1)' : 'brightness(1)',
|
||||
}}
|
||||
>
|
||||
{/* Highlight on the egg - uses color variants instead of white */}
|
||||
@@ -538,133 +633,181 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
renderLegacySpecialMark(effectiveSpecialMark)
|
||||
))}
|
||||
|
||||
{/* Crack pattern based on docs/aprovado.svg when cracking is true */}
|
||||
{cracking && (
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none w-full h-full"
|
||||
viewBox="0 0 120 125"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Main horizontal crack (adapted from aprovado.svg) */}
|
||||
<path
|
||||
d="M10 62
|
||||
L20 60
|
||||
L30 64
|
||||
L40 59
|
||||
L50 65
|
||||
L60 58
|
||||
L70 66
|
||||
L80 57
|
||||
L90 67
|
||||
L100 59
|
||||
L110 65"
|
||||
stroke="rgba(0, 0, 0, 0.6)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Crack pattern - stage-specific paths that grow outward from center */}
|
||||
{(cracking || tourShowCrack) && (() => {
|
||||
// Legacy cracking shows full crack; tour uses progressive stage-specific paths
|
||||
const level = cracking ? 3 : tourCrackLevel;
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none w-full h-full transition-opacity duration-300"
|
||||
viewBox="0 0 120 125"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{/*
|
||||
Stage-specific crack paths.
|
||||
Each level has its OWN distinct paths that expand outward from the egg center.
|
||||
The crack grows from a small central cluster to full-width fracture.
|
||||
|
||||
{/* Secondary cracks (adapted from aprovado.svg) */}
|
||||
<path
|
||||
d="M30 64 L28 70"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M50 65 L53 71"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 58 L57 52"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M80 57 L82 50"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 67 L95 72"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M100 59 L97 53"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M110 65 L113 69"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
Viewbox center is roughly (60, 62).
|
||||
Level 0: tiny central crack (~3-4 small connected segments near center)
|
||||
Level 1: extends left/right from center, first branches
|
||||
Level 2: reaches further toward edges, more fracture detail
|
||||
Level 3: crack reaches near shell edges, dense branching
|
||||
*/}
|
||||
|
||||
{/* Additional micro-cracks for detail */}
|
||||
<path
|
||||
d="M40 59 L38 55"
|
||||
stroke="rgba(0, 0, 0, 0.25)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M70 66 L73 70"
|
||||
stroke="rgba(0, 0, 0, 0.25)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 60 L18 56"
|
||||
stroke="rgba(0, 0, 0, 0.2)"
|
||||
strokeWidth="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* ── Level 0: Small central crack ── */}
|
||||
{/* A few short connected segments clustered around the center of the egg */}
|
||||
{level === 0 && (<>
|
||||
{/* Main tiny crack: ~15px wide, centered */}
|
||||
<path
|
||||
d="M53 63 L57 60 L63 64 L67 61"
|
||||
stroke="rgba(0, 0, 0, 0.5)"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Tiny upward branch from center */}
|
||||
<path
|
||||
d="M57 60 L56 57"
|
||||
stroke="rgba(0, 0, 0, 0.35)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Tiny downward branch */}
|
||||
<path
|
||||
d="M63 64 L65 67"
|
||||
stroke="rgba(0, 0, 0, 0.35)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Subtle highlight alongside main crack */}
|
||||
<path
|
||||
d="M54 64 L58 61 L64 65"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>)}
|
||||
|
||||
{/* Crack highlights for depth (following the main crack pattern) */}
|
||||
<path
|
||||
d="M10 63
|
||||
L20 61
|
||||
L30 65
|
||||
L40 60
|
||||
L50 66
|
||||
L60 59
|
||||
L70 67
|
||||
L80 58
|
||||
L90 68
|
||||
L100 60
|
||||
L110 66"
|
||||
stroke="rgba(255, 255, 255, 0.15)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* ── Level 1: Medium crack expanding from center ── */}
|
||||
{/* Crack extends ~30px wide, first real branches appear */}
|
||||
{level === 1 && (<>
|
||||
{/* Main crack: wider than level 0, extends left and right */}
|
||||
<path
|
||||
d="M42 61 L48 64 L53 60 L60 65 L67 59 L73 63 L78 60"
|
||||
stroke="rgba(0, 0, 0, 0.55)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M43 62 L49 65 L54 61 L61 66 L68 60 L74 64"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Branch: upward left */}
|
||||
<path d="M48 64 L46 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branch: upward from center-right */}
|
||||
<path d="M67 59 L65 54" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branch: downward right */}
|
||||
<path d="M73 63 L76 68" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Small micro-branch */}
|
||||
<path d="M53 60 L51 56" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
</>)}
|
||||
|
||||
{/* Secondary crack highlights */}
|
||||
<path
|
||||
d="M30 65 L28 71"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 59 L57 53"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* ── Level 2: Larger crack reaching toward sides ── */}
|
||||
{/* Crack extends ~60px wide, more branching detail */}
|
||||
{level === 2 && (<>
|
||||
{/* Main crack: extends well toward both sides */}
|
||||
<path
|
||||
d="M30 63 L37 60 L44 65 L52 59 L60 64 L68 58 L76 63 L83 59 L90 64"
|
||||
stroke="rgba(0, 0, 0, 0.6)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M31 64 L38 61 L45 66 L53 60 L61 65 L69 59 L77 64 L84 60"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Branches: left side */}
|
||||
<path d="M37 60 L34 55" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
<path d="M44 65 L41 71" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branches: center */}
|
||||
<path d="M52 59 L50 53" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
<path d="M60 64 L63 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branches: right side */}
|
||||
<path d="M68 58 L66 52" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
<path d="M76 63 L79 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
<path d="M83 59 L86 54" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Micro-cracks */}
|
||||
<path d="M50 53 L48 50" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M63 70 L66 73" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
</>)}
|
||||
|
||||
{/* ── Level 3: Full crack reaching shell edges ── */}
|
||||
{/* Crack spans nearly the full width, dense fracture network */}
|
||||
{level >= 3 && (<>
|
||||
{/* Main crack: nearly full width of egg */}
|
||||
<path
|
||||
d="M15 62 L23 59 L32 64 L40 58 L50 65 L60 57 L70 64 L80 58 L88 63 L96 59 L105 64"
|
||||
stroke="rgba(0, 0, 0, 0.65)"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M16 63 L24 60 L33 65 L41 59 L51 66 L61 58 L71 65 L81 59 L89 64 L97 60"
|
||||
stroke="rgba(255, 255, 255, 0.13)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Heavy branches: left region */}
|
||||
<path d="M23 59 L19 53" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M32 64 L28 72" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M28 72 L25 76" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Heavy branches: center-left */}
|
||||
<path d="M40 58 L37 51" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M50 65 L47 73" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M37 51 L35 47" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
|
||||
{/* Heavy branches: center */}
|
||||
<path d="M60 57 L58 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M60 57 L63 68" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
{/* Heavy branches: center-right */}
|
||||
<path d="M70 64 L73 71" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M80 58 L83 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M83 50 L86 46" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
|
||||
{/* Heavy branches: right region */}
|
||||
<path d="M88 63 L91 70" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M96 59 L99 52" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M105 64 L109 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
{/* Micro-cracks (tertiary detail) */}
|
||||
<path d="M47 73 L44 77" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M73 71 L76 75" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M58 50 L55 46" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M19 53 L17 49" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
|
||||
<path d="M99 52 L102 48" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
|
||||
</>)}
|
||||
</svg>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title display for special eggs */}
|
||||
{blobbi?.title && (
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import './styles/egg-animations.css';
|
||||
|
||||
// Components
|
||||
export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic';
|
||||
export { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from './components/EggGraphic';
|
||||
export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer';
|
||||
|
||||
// Hooks
|
||||
|
||||
@@ -320,6 +320,49 @@
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Tour Visual State Animations
|
||||
========================================== */
|
||||
|
||||
/* Shell opening: scale up slightly then fade out with blur */
|
||||
@keyframes egg-tour-open {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
filter: brightness(1.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0;
|
||||
filter: brightness(2) blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-tour-open {
|
||||
animation: egg-tour-open 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Pulsing glow for the "waiting for click" tour state */
|
||||
@keyframes egg-tour-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-tour-glow {
|
||||
animation: egg-tour-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Responsive adjustments
|
||||
========================================== */
|
||||
@@ -351,7 +394,9 @@
|
||||
.animate-egg-sweat-drop,
|
||||
.animate-egg-dust-particle,
|
||||
.animate-egg-spiral,
|
||||
.animate-egg-sparkle {
|
||||
.animate-egg-sparkle,
|
||||
.animate-egg-tour-glow,
|
||||
.animate-egg-tour-open {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -393,3 +438,354 @@
|
||||
filter: grayscale(1) contrast(1.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Onboarding Hatching Ceremony Animations
|
||||
========================================== */
|
||||
|
||||
/* Soft breathing pulse for the egg before interaction */
|
||||
@keyframes egg-onboard-breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: brightness(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015);
|
||||
filter: brightness(1.03) drop-shadow(0 0 30px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-breathe {
|
||||
animation: egg-onboard-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Screen-filling radial glow that expands from center on hatch */
|
||||
@keyframes onboard-glow-expand {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-expand {
|
||||
animation: onboard-glow-expand 1.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle lingering glow fade after hatch - holds then fades */
|
||||
@keyframes onboard-glow-linger {
|
||||
0% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-linger {
|
||||
animation: onboard-glow-linger 7s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Sentimental text fade in - very slow, dreamlike */
|
||||
@keyframes onboard-text-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-text-reveal {
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Delayed text reveal for secondary text */
|
||||
.animate-onboard-text-reveal-delay {
|
||||
opacity: 0;
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
/* Soft fade out for transition between phases */
|
||||
@keyframes onboard-soft-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-out {
|
||||
animation: onboard-soft-fade-out 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Soft fade in */
|
||||
@keyframes onboard-soft-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-in {
|
||||
animation: onboard-soft-fade-in 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Floating particles that drift upward from the egg */
|
||||
@keyframes onboard-particle-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.5);
|
||||
}
|
||||
20% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-120px) scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle twinkle - stays in place, pulses brightness */
|
||||
@keyframes onboard-sparkle-twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle drift - gentle floating motion */
|
||||
@keyframes onboard-sparkle-drift {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.3);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-8px) scale(1);
|
||||
}
|
||||
80% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-25px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px) scale(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Egg entrance - subtle float up from darkness */
|
||||
@keyframes egg-onboard-entrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-entrance {
|
||||
animation: egg-onboard-entrance 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Egg shake intensifying - for crack stages */
|
||||
@keyframes egg-onboard-shake-light {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
25% { transform: translateX(-3px) rotate(-2deg); }
|
||||
75% { transform: translateX(3px) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-medium {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
20% { transform: translateX(-5px) rotate(-3deg); }
|
||||
40% { transform: translateX(4px) rotate(2deg); }
|
||||
60% { transform: translateX(-4px) rotate(-2deg); }
|
||||
80% { transform: translateX(5px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-heavy {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
10% { transform: translateX(-6px) rotate(-4deg); }
|
||||
20% { transform: translateX(5px) rotate(3deg); }
|
||||
30% { transform: translateX(-7px) rotate(-3deg); }
|
||||
40% { transform: translateX(6px) rotate(4deg); }
|
||||
50% { transform: translateX(-5px) rotate(-2deg); }
|
||||
60% { transform: translateX(7px) rotate(3deg); }
|
||||
70% { transform: translateX(-6px) rotate(-4deg); }
|
||||
80% { transform: translateX(5px) rotate(2deg); }
|
||||
90% { transform: translateX(-4px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-light {
|
||||
animation: egg-onboard-shake-light 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-medium {
|
||||
animation: egg-onboard-shake-medium 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-heavy {
|
||||
animation: egg-onboard-shake-heavy 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* Final burst - egg explodes into light */
|
||||
@keyframes egg-onboard-burst {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
filter: brightness(2.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0;
|
||||
filter: brightness(4) blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-burst {
|
||||
animation: egg-onboard-burst 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Screen flash on hatch */
|
||||
@keyframes onboard-screen-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-screen-flash {
|
||||
animation: onboard-screen-flash 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle continue prompt pulse */
|
||||
@keyframes onboard-continue-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-continue-pulse {
|
||||
animation: onboard-continue-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slow rotating golden incandescence behind hatched blobbi */
|
||||
@keyframes onboard-golden-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(90deg) scale(1.06);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(270deg) scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-rotate {
|
||||
animation: onboard-golden-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Golden glow fade-in */
|
||||
@keyframes onboard-golden-fadein {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion overrides for onboarding */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-egg-onboard-breathe,
|
||||
.animate-onboard-glow-expand,
|
||||
.animate-onboard-glow-linger,
|
||||
.animate-onboard-text-reveal,
|
||||
.animate-onboard-text-reveal-delay,
|
||||
.animate-onboard-soft-fade-out,
|
||||
.animate-onboard-soft-fade-in,
|
||||
.animate-egg-onboard-entrance,
|
||||
.animate-egg-onboard-shake-light,
|
||||
.animate-egg-onboard-shake-medium,
|
||||
.animate-egg-onboard-shake-heavy,
|
||||
.animate-egg-onboard-burst,
|
||||
.animate-onboard-screen-flash,
|
||||
.animate-onboard-continue-pulse,
|
||||
.animate-onboard-golden-rotate,
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* BlobbiHatchingCeremony - Immersive hatching experience for every new egg
|
||||
*
|
||||
* Flow:
|
||||
* 1. Dark screen, egg silently created in background
|
||||
* 2. Huge breathing egg appears. No text. No UI.
|
||||
* 3. Click egg 4 times through crack stages with intensifying shakes
|
||||
* 4. Final click -> egg bursts into light, actual hatch mutation fires
|
||||
* 5. Flash clears -> hatched baby blobbi revealed center screen with glow/sparkles
|
||||
* 6. Typewriter dialog appears below blobbi (click to complete line / advance)
|
||||
* 7. Naming prompt, then ceremony complete
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
INITIAL_BLOBBONAUT_COINS,
|
||||
STAT_MAX,
|
||||
buildBlobbonautTags,
|
||||
updateBlobbonautTags,
|
||||
updateBlobbiTags,
|
||||
type BlobbonautProfile,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
generateEggPreview,
|
||||
previewToEventTags,
|
||||
previewToBlobbiCompanion,
|
||||
type BlobbiEggPreview,
|
||||
} from '../lib/blobbi-preview';
|
||||
|
||||
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BIRTH_DIALOG: string[] = [
|
||||
'Something stirs...',
|
||||
'A tiny life has chosen you. It knows only warmth, and your presence.',
|
||||
];
|
||||
|
||||
const NAMING_DIALOG = 'Every life deserves a name.\nWhat will you call this one?';
|
||||
|
||||
// ─── Phase Machine ────────────────────────────────────────────────────────────
|
||||
|
||||
type CeremonyPhase =
|
||||
| 'loading'
|
||||
| 'egg'
|
||||
| 'crack_1'
|
||||
| 'crack_2'
|
||||
| 'crack_3'
|
||||
| 'hatching' // egg burst + hatch mutation
|
||||
| 'reveal' // flash clearing, baby blobbi fading in with glow
|
||||
| 'dialog' // typewriter dialog lines
|
||||
| 'naming'
|
||||
| 'complete';
|
||||
|
||||
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
function useTypewriter(fullText: string, active: boolean, speed = 35) {
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const indexRef = useRef(0);
|
||||
|
||||
// Reset when text changes
|
||||
useEffect(() => {
|
||||
setDisplayed('');
|
||||
setDone(false);
|
||||
indexRef.current = 0;
|
||||
}, [fullText]);
|
||||
|
||||
// Run typewriter
|
||||
useEffect(() => {
|
||||
if (!active || done) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
indexRef.current++;
|
||||
const next = fullText.slice(0, indexRef.current);
|
||||
setDisplayed(next);
|
||||
if (indexRef.current >= fullText.length) {
|
||||
setDone(true);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
}, speed);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [active, done, fullText, speed]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
setDisplayed(fullText);
|
||||
setDone(true);
|
||||
}, [fullText]);
|
||||
|
||||
return { displayed, done, complete };
|
||||
}
|
||||
|
||||
// Module-level guard: prevents duplicate egg creation if the component remounts
|
||||
// (e.g. React strict mode, parent re-render causing unmount/remount).
|
||||
// Tracks pubkeys that have already started setup in this browser session.
|
||||
const setupInFlightFor = new Set<string>();
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiHatchingCeremonyProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
onComplete?: () => void;
|
||||
/** If provided, skip egg creation and start from the cracking phase with this existing egg. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/** If true, only create the egg and skip the hatching ceremony. The egg stays an egg. */
|
||||
eggOnly?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiHatchingCeremony({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
existingCompanion,
|
||||
eggOnly = false,
|
||||
}: BlobbiHatchingCeremonyProps) {
|
||||
const isExistingEgg = !!existingCompanion;
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: authorData } = useAuthor(user?.pubkey);
|
||||
|
||||
// ── Core state ──
|
||||
const [phase, setPhase] = useState<CeremonyPhase>('loading');
|
||||
const [preview, setPreview] = useState<BlobbiEggPreview | null>(null);
|
||||
const [name, setName] = useState(existingCompanion?.name ?? '');
|
||||
const [isNaming, setIsNaming] = useState(false);
|
||||
const [eggVisible, setEggVisible] = useState(false);
|
||||
|
||||
// Reveal phase state
|
||||
const [blobbiVisible, setBlobbiVisible] = useState(false);
|
||||
const [showFlash, setShowFlash] = useState(false);
|
||||
const [, setShowRevealGlow] = useState(false);
|
||||
const [fadeOut, setFadeOut] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [dialogLineIndex, setDialogLineIndex] = useState(0);
|
||||
const [dialogActive, setDialogActive] = useState(false);
|
||||
const [namingVisible, setNamingVisible] = useState(false);
|
||||
|
||||
// Refs
|
||||
const setupAttempted = useRef(false);
|
||||
const profileRef = useRef(profile);
|
||||
profileRef.current = profile;
|
||||
const previewRef = useRef(preview);
|
||||
previewRef.current = preview;
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const eggContainerRef = useRef<HTMLDivElement>(null);
|
||||
const entrancePlayed = useRef(false);
|
||||
const eggTagsRef = useRef<string[][] | null>(null);
|
||||
|
||||
// ── Companion visuals ──
|
||||
const eggCompanion = useMemo(
|
||||
() => preview ? previewToBlobbiCompanion(preview) : null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[preview?.d],
|
||||
);
|
||||
|
||||
// Baby companion (same visual data but stage=baby)
|
||||
const babyCompanion = useMemo((): BlobbiCompanion | null => {
|
||||
if (!eggCompanion) return null;
|
||||
return { ...eggCompanion, stage: 'baby', state: 'active' };
|
||||
}, [eggCompanion]);
|
||||
|
||||
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
|
||||
|
||||
// ── Typewriter for current dialog line ──
|
||||
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
|
||||
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
|
||||
|
||||
const namingTypewriter = useTypewriter(NAMING_DIALOG, namingVisible);
|
||||
|
||||
// ── Fast-path setup for existing eggs (no publishing needed) ──
|
||||
useEffect(() => {
|
||||
if (!isExistingEgg || setupAttempted.current || !existingCompanion) return;
|
||||
setupAttempted.current = true;
|
||||
|
||||
// Build a minimal preview from the existing companion
|
||||
const fakePreview: BlobbiEggPreview = {
|
||||
d: existingCompanion.d,
|
||||
petId: existingCompanion.d,
|
||||
ownerPubkey: user?.pubkey ?? '',
|
||||
name: existingCompanion.name,
|
||||
stage: 'egg',
|
||||
state: 'active',
|
||||
seed: existingCompanion.seed ?? '',
|
||||
stats: {
|
||||
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
|
||||
happiness: existingCompanion.stats.happiness ?? STAT_MAX,
|
||||
health: existingCompanion.stats.health ?? STAT_MAX,
|
||||
hygiene: existingCompanion.stats.hygiene ?? STAT_MAX,
|
||||
energy: existingCompanion.stats.energy ?? STAT_MAX,
|
||||
},
|
||||
visualTraits: existingCompanion.visualTraits,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
setPreview(fakePreview);
|
||||
previewRef.current = fakePreview;
|
||||
eggTagsRef.current = existingCompanion.allTags;
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isExistingEgg, existingCompanion?.d]);
|
||||
|
||||
// ── Silent setup: create profile + egg (new egg flow only) ──
|
||||
useEffect(() => {
|
||||
if (isExistingEgg) return; // Skip for existing eggs
|
||||
if (setupAttempted.current || !user?.pubkey) return;
|
||||
// Module-level guard: if another mount already started setup for this pubkey, skip
|
||||
if (setupInFlightFor.has(user.pubkey)) return;
|
||||
setupAttempted.current = true;
|
||||
setupInFlightFor.add(user.pubkey);
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const currentProfile = profileRef.current;
|
||||
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
|
||||
|
||||
// 1. Create profile if needed
|
||||
if (!currentProfile) {
|
||||
const suggestedName =
|
||||
authorData?.metadata?.display_name ||
|
||||
authorData?.metadata?.name ||
|
||||
'Blobbonaut';
|
||||
|
||||
const baseTags = buildBlobbonautTags(user.pubkey);
|
||||
const tagsWithName = [
|
||||
...baseTags,
|
||||
['name', suggestedName],
|
||||
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
|
||||
];
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: tagsWithName,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
invalidateProfile();
|
||||
latestProfileTags = tagsWithName;
|
||||
}
|
||||
|
||||
// 2. Generate and publish egg
|
||||
const eggPreview = generateEggPreview(user.pubkey, 'Egg');
|
||||
setPreview(eggPreview);
|
||||
previewRef.current = eggPreview;
|
||||
|
||||
const eggTags = previewToEventTags(eggPreview);
|
||||
eggTagsRef.current = eggTags;
|
||||
|
||||
const eggEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: 'A new Blobbi egg!',
|
||||
tags: eggTags,
|
||||
created_at: eggPreview.createdAt,
|
||||
});
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 3. Update profile with has[] entry
|
||||
if (latestProfileTags) {
|
||||
const existingHas = latestProfileTags
|
||||
.filter(([k]) => k === 'has')
|
||||
.map(([, v]) => v);
|
||||
const newHas = [...existingHas, eggPreview.d];
|
||||
|
||||
const updatedTags = updateBlobbonautTags(latestProfileTags, {
|
||||
has: newHas,
|
||||
});
|
||||
|
||||
const updatedProfileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(updatedProfileEvent);
|
||||
}
|
||||
|
||||
setStoredSelectedD(eggPreview.d);
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Setup failed:', error);
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Failed to set up your Blobbi. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
// Clear module-level guard so future adoptions can create new eggs
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(setup, 600);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// If the timer was cleared before setup ran, release the guard
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) profileRef.current = profile;
|
||||
}, [profile]);
|
||||
|
||||
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
|
||||
useEffect(() => {
|
||||
if (!eggOnly || !eggVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eggOnly, eggVisible, onComplete]);
|
||||
|
||||
// Play entrance animation once
|
||||
useEffect(() => {
|
||||
if (eggVisible && !entrancePlayed.current && eggContainerRef.current) {
|
||||
entrancePlayed.current = true;
|
||||
const el = eggContainerRef.current;
|
||||
el.classList.add('animate-egg-onboard-entrance');
|
||||
const onEnd = () => {
|
||||
el.classList.remove('animate-egg-onboard-entrance');
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
}
|
||||
}, [eggVisible]);
|
||||
|
||||
// ── Shake (DOM-only, no re-render) ──
|
||||
const triggerShake = useCallback((cls: string) => {
|
||||
const el = eggContainerRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove(
|
||||
'animate-egg-onboard-shake-light',
|
||||
'animate-egg-onboard-shake-medium',
|
||||
'animate-egg-onboard-shake-heavy',
|
||||
);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(cls);
|
||||
}, []);
|
||||
|
||||
// ── Execute the actual hatch: egg -> baby ──
|
||||
const executeHatch = useCallback(async () => {
|
||||
const tags = eggTagsRef.current;
|
||||
if (!tags) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const babyTags = updateBlobbiTags(tags, {
|
||||
stage: 'baby',
|
||||
state: 'active',
|
||||
hunger: STAT_MAX.toString(),
|
||||
happiness: STAT_MAX.toString(),
|
||||
health: STAT_MAX.toString(),
|
||||
hygiene: STAT_MAX.toString(),
|
||||
energy: STAT_MAX.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const babyName = previewRef.current?.name ?? 'Egg';
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${babyName} is a baby Blobbi.`,
|
||||
tags: babyTags,
|
||||
});
|
||||
|
||||
eggTagsRef.current = babyTags;
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
}, [publishEvent, updateCompanionEvent, invalidateCompanion]);
|
||||
|
||||
// ── Egg click ──
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (phase === 'egg') {
|
||||
triggerShake('animate-egg-onboard-shake-light');
|
||||
setPhase('crack_1');
|
||||
} else if (phase === 'crack_1') {
|
||||
triggerShake('animate-egg-onboard-shake-medium');
|
||||
setPhase('crack_2');
|
||||
} else if (phase === 'crack_2') {
|
||||
triggerShake('animate-egg-onboard-shake-heavy');
|
||||
setPhase('crack_3');
|
||||
} else if (phase === 'crack_3') {
|
||||
// Final click -> hatch!
|
||||
setPhase('hatching');
|
||||
setShowFlash(true);
|
||||
|
||||
// Fire the actual hatch mutation
|
||||
executeHatch().catch(console.error);
|
||||
|
||||
// After flash, reveal the baby
|
||||
setTimeout(() => {
|
||||
setShowFlash(false);
|
||||
setShowRevealGlow(true);
|
||||
setPhase('reveal');
|
||||
|
||||
// Fade in blobbi
|
||||
setTimeout(() => setBlobbiVisible(true), 400);
|
||||
|
||||
// After blobbi settles, start dialog
|
||||
setTimeout(() => {
|
||||
setPhase('dialog');
|
||||
setDialogLineIndex(0);
|
||||
setDialogActive(true);
|
||||
}, 2200);
|
||||
}, 1400);
|
||||
}
|
||||
}, [phase, triggerShake, executeHatch]);
|
||||
|
||||
// ── Dialog click: complete line or advance ──
|
||||
const handleDialogClick = useCallback(() => {
|
||||
if (phase !== 'dialog') return;
|
||||
|
||||
if (!dialogTypewriter.done) {
|
||||
// Complete the current line instantly
|
||||
dialogTypewriter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to next line
|
||||
const nextIndex = dialogLineIndex + 1;
|
||||
if (nextIndex < BIRTH_DIALOG.length) {
|
||||
setDialogActive(false);
|
||||
setDialogLineIndex(nextIndex);
|
||||
// Small pause before next line starts
|
||||
setTimeout(() => setDialogActive(true), 150);
|
||||
} else {
|
||||
// All lines done -> naming
|
||||
setDialogActive(false);
|
||||
setTimeout(() => {
|
||||
setPhase('naming');
|
||||
setTimeout(() => {
|
||||
setNamingVisible(true);
|
||||
setTimeout(() => nameInputRef.current?.focus(), 600);
|
||||
}, 200);
|
||||
}, 400);
|
||||
}
|
||||
}, [phase, dialogTypewriter, dialogLineIndex]);
|
||||
|
||||
// ── Complete ceremony ──
|
||||
const completeCeremony = useCallback(async (finalName: string) => {
|
||||
try {
|
||||
// Update egg/baby name if changed
|
||||
const currentTags = eggTagsRef.current;
|
||||
if (currentTags && finalName !== (previewRef.current?.name ?? 'Egg')) {
|
||||
const namedTags = updateBlobbiTags(currentTags, { name: finalName });
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${finalName} is a baby Blobbi.`,
|
||||
tags: namedTags,
|
||||
});
|
||||
updateCompanionEvent(event);
|
||||
}
|
||||
|
||||
// Mark onboarding done
|
||||
const currentProfile = profileRef.current;
|
||||
if (currentProfile) {
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Failed to persist completion:', error);
|
||||
}
|
||||
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
|
||||
|
||||
// ── Naming submit ──
|
||||
const handleNameSubmit = useCallback(async () => {
|
||||
if (isNaming || !name.trim()) return;
|
||||
setIsNaming(true);
|
||||
|
||||
try {
|
||||
await completeCeremony(name.trim());
|
||||
setNamingVisible(false);
|
||||
// Fade to white, then complete
|
||||
setTimeout(() => {
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Naming failed:', error);
|
||||
toast({
|
||||
title: 'Failed to save name',
|
||||
description: 'Your Blobbi was created, but the name could not be saved.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
} finally {
|
||||
setIsNaming(false);
|
||||
}
|
||||
}, [name, isNaming, completeCeremony, onComplete]);
|
||||
|
||||
// ── Tour visual state for EggGraphic crack rendering ──
|
||||
const tourVisualState = useMemo(() => {
|
||||
switch (phase) {
|
||||
case 'crack_1': return 'crack_stage_1' as const;
|
||||
case 'crack_2': return 'crack_stage_2' as const;
|
||||
case 'crack_3': return 'crack_stage_3' as const;
|
||||
case 'hatching': return 'opening' as const;
|
||||
default: return 'idle' as const;
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const isEggPhase = phase === 'egg' || phase === 'crack_1' || phase === 'crack_2' || phase === 'crack_3';
|
||||
const isHatching = phase === 'hatching';
|
||||
const showBaby = phase === 'reveal' || phase === 'dialog' || phase === 'naming';
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)' }}
|
||||
>
|
||||
<div
|
||||
className="absolute size-32 rounded-full opacity-20 animate-pulse"
|
||||
style={{ background: `radial-gradient(circle, ${eggColor}40 0%, transparent 70%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-hidden select-none"
|
||||
style={{
|
||||
background: showBaby
|
||||
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
|
||||
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
|
||||
transition: 'background 2s ease-out',
|
||||
}}
|
||||
onClick={phase === 'dialog' ? handleDialogClick : undefined}
|
||||
>
|
||||
{/* ── Ambient background glow (egg phase only) ── */}
|
||||
{!showBaby && (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity"
|
||||
style={{
|
||||
transitionDuration: '3000ms',
|
||||
background: `radial-gradient(ellipse at 50% 50%, ${eggColor}30 0%, transparent 60%)`,
|
||||
opacity: (isEggPhase || isHatching) ? 0.07 : 0.05,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Floating particles (egg phase) ── */}
|
||||
{isEggPhase && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 2 + (i % 3),
|
||||
height: 2 + (i % 3),
|
||||
left: `${20 + (i * 12) % 60}%`,
|
||||
bottom: '40%',
|
||||
backgroundColor: `${eggColor}40`,
|
||||
animation: `onboard-particle-rise ${4 + i * 0.7}s ease-out ${i * 0.8}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The Egg ── */}
|
||||
{(isEggPhase || isHatching) && eggCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
ref={eggContainerRef}
|
||||
className={cn(
|
||||
'cursor-pointer relative',
|
||||
eggVisible ? '' : 'opacity-0',
|
||||
eggVisible && isEggPhase && 'animate-egg-onboard-breathe',
|
||||
isHatching && 'animate-egg-onboard-burst',
|
||||
)}
|
||||
onClick={isEggPhase ? handleEggClick : undefined}
|
||||
>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-full blur-2xl transition-opacity duration-1000"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${eggColor}50 0%, transparent 70%)`,
|
||||
opacity: phase === 'crack_3' ? 0.5 : phase === 'crack_2' ? 0.35 : phase === 'crack_1' ? 0.25 : 0.15,
|
||||
}}
|
||||
/>
|
||||
<BlobbiStageVisual
|
||||
companion={eggCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-56 sm:size-64 md:size-72"
|
||||
tourVisualState={tourVisualState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Screen flash ── */}
|
||||
{showFlash && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white animate-onboard-screen-flash pointer-events-none"
|
||||
style={{ zIndex: 80 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Hatched baby blobbi with golden incandescence ── */}
|
||||
{showBaby && babyCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
style={{ paddingBottom: '18%' }}
|
||||
>
|
||||
{/* Rotating golden incandescence */}
|
||||
<div className={cn(
|
||||
'absolute animate-onboard-golden-fadein',
|
||||
blobbiVisible ? '' : 'opacity-0',
|
||||
)}>
|
||||
<div
|
||||
className="animate-onboard-golden-rotate"
|
||||
style={{
|
||||
width: 900,
|
||||
height: 900,
|
||||
background: `conic-gradient(
|
||||
from 0deg,
|
||||
rgba(255, 250, 230, 0.18) 0deg,
|
||||
rgba(255, 245, 210, 0.50) 50deg,
|
||||
rgba(255, 250, 235, 0.22) 100deg,
|
||||
rgba(255, 248, 220, 0.15) 150deg,
|
||||
rgba(255, 245, 210, 0.48) 210deg,
|
||||
rgba(255, 250, 230, 0.20) 270deg,
|
||||
rgba(255, 248, 220, 0.15) 320deg,
|
||||
rgba(255, 250, 230, 0.18) 360deg
|
||||
)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bright white-gold shine directly behind blobbi */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 320,
|
||||
height: 320,
|
||||
background: 'radial-gradient(circle, rgba(255,255,245,0.70) 0%, rgba(255,250,225,0.30) 40%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wider golden halo */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity [transition-duration:2000ms]',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 700,
|
||||
height: 700,
|
||||
background: 'radial-gradient(circle, rgba(255, 248, 210, 0.40) 0%, rgba(255, 240, 190, 0.18) 40%, transparent 65%)',
|
||||
filter: 'blur(15px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Sparkles everywhere ── */}
|
||||
|
||||
{/* Inner ring - bright twinkling sparkles */}
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const angle = (i / 20) * Math.PI * 2;
|
||||
const r = 80 + (i % 4) * 35;
|
||||
const size = 4 + (i % 3) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`inner-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 2 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.4) 40%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,130,1) 0%, rgba(255,220,80,0.3) 50%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer ring - larger, slower sparkles */}
|
||||
{Array.from({ length: 16 }).map((_, i) => {
|
||||
const angle = (i / 16) * Math.PI * 2 + 0.3;
|
||||
const r = 170 + (i % 3) * 50;
|
||||
const size = 5 + (i % 4) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`outer-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 3 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
|
||||
: 'radial-gradient(circle, rgba(255,235,120,0.85) 0%, transparent 60%)',
|
||||
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.25}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scattered wide-field sparkles */}
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 2.7 + 1.3) * 0.5 + 0.5) * 80 + 10;
|
||||
const y = (Math.cos(i * 3.1 + 0.7) * 0.5 + 0.5) * 70 + 10;
|
||||
const size = 3 + (i % 3) * 2;
|
||||
return (
|
||||
<div
|
||||
key={`field-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
borderRadius: '50%',
|
||||
background: i % 4 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.95) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,160,0.8) 0%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${2 + (i % 7) * 0.6}s ease-in-out ${i * 0.18}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drifting light motes rising from below */}
|
||||
{Array.from({ length: 10 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 1.9) * 0.5 + 0.5) * 70 + 15;
|
||||
return (
|
||||
<div
|
||||
key={`drift-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 5 + (i % 3) * 3,
|
||||
height: 5 + (i % 3) * 3,
|
||||
left: `${x}%`,
|
||||
bottom: '20%',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(255,250,200,0.9) 0%, rgba(255,230,120,0.3) 50%, transparent 100%)',
|
||||
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.5}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The baby blobbi */}
|
||||
<div className={cn(
|
||||
'relative transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}>
|
||||
<BlobbiStageVisual
|
||||
companion={babyCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Dialog text (no box, blur behind) ── */}
|
||||
{phase === 'dialog' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className="relative max-w-md w-full text-center">
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Speaker */}
|
||||
<div className="relative">
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter text */}
|
||||
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[3em]">
|
||||
{dialogTypewriter.displayed}
|
||||
{!dialogTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Advance indicator */}
|
||||
{dialogTypewriter.done && (
|
||||
<div className="mt-4 animate-onboard-continue-pulse">
|
||||
<span className="text-xs text-white/30">▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Naming ── */}
|
||||
{phase === 'naming' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className={cn(
|
||||
'relative max-w-md w-full text-center',
|
||||
namingVisible ? 'animate-onboard-soft-fade-in' : 'opacity-0',
|
||||
)}>
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Speaker */}
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter question */}
|
||||
<p className="text-base sm:text-lg text-white/85 leading-relaxed font-light mb-6 min-h-[1.5em] whitespace-pre-line">
|
||||
{namingTypewriter.displayed}
|
||||
{!namingTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Input + confirm (appear after typewriter done) */}
|
||||
{namingTypewriter.done && (
|
||||
<div className="space-y-3 animate-onboard-soft-fade-in">
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="..."
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
className={cn(
|
||||
'text-center text-lg font-light h-12',
|
||||
'bg-white/10 border-transparent text-white placeholder:text-white/30',
|
||||
'focus:bg-white/[0.25] focus:border-transparent focus:ring-0 focus:outline-none',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'focus:shadow-[0_0_15px_rgba(255,255,255,0.15),0_0_40px_rgba(255,250,230,0.08)]',
|
||||
'transition-all duration-300',
|
||||
'rounded-full transition-shadow duration-500',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) handleNameSubmit();
|
||||
}}
|
||||
/>
|
||||
|
||||
{name.trim() && (
|
||||
<Button
|
||||
onClick={handleNameSubmit}
|
||||
disabled={isNaming}
|
||||
className={cn(
|
||||
'max-w-[12rem] mx-auto h-10 px-8 text-sm font-light tracking-wide',
|
||||
'bg-white/15 hover:bg-white/22 text-white/80 border-transparent',
|
||||
'rounded-full transition-all duration-300',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
)}
|
||||
variant="ghost"
|
||||
>
|
||||
That's the one.
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fade to white on completion ── */}
|
||||
{fadeOut && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white pointer-events-none"
|
||||
style={{
|
||||
zIndex: 90,
|
||||
animation: 'blobbi-fade-to-white 2s ease-in forwards',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,19 @@
|
||||
/**
|
||||
* BlobbiOnboardingFlow - Main component that orchestrates the onboarding steps
|
||||
*
|
||||
* This component renders the appropriate onboarding step based on the user's
|
||||
* actual profile state. The initial step is derived from whether the profile
|
||||
* exists - not hardcoded.
|
||||
*
|
||||
* MODES:
|
||||
* 1. Full onboarding (default): Auto profile creation → Adoption question → Preview
|
||||
* 2. Adoption only (adoptionOnly=true): Skip directly to Preview for existing profiles
|
||||
*
|
||||
* IMPORTANT: This component should only be rendered when:
|
||||
* - User has no profile (auto-creates profile using kind 0 name)
|
||||
* - User has profile but no pets (shows adoption)
|
||||
* - User wants to adopt another Blobbi (adoptionOnly mode)
|
||||
*
|
||||
* Profile creation is now automatic - no manual name entry step is needed.
|
||||
* BlobbiOnboardingFlow - Immersive hatching ceremony for every new Blobbi
|
||||
*
|
||||
* Every new egg goes through the hatching ceremony - whether it's a user's
|
||||
* first Blobbi or their tenth. The ceremony creates the egg silently in the
|
||||
* background and presents a wordless, emotional hatching experience.
|
||||
*
|
||||
* The `adoptionOnly` prop is accepted for API compatibility but no longer
|
||||
* changes the flow - every egg gets the full ceremony.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useBlobbiOnboarding } from '../hooks/useBlobbiOnboarding';
|
||||
import { BlobbiAdoptionStep } from './BlobbiAdoptionStep';
|
||||
import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
|
||||
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
interface BlobbiOnboardingFlowProps {
|
||||
/** Current profile (null if doesn't exist) */
|
||||
@@ -43,9 +30,11 @@ interface BlobbiOnboardingFlowProps {
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
/** Called when onboarding is complete */
|
||||
onComplete?: () => void;
|
||||
/**
|
||||
* If true, skip profile creation and adoption question, go directly to preview.
|
||||
* Use this for "Adopt another Blobbi" flow for existing users.
|
||||
/** If provided, skip egg creation and use this existing egg for the ceremony. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/**
|
||||
* Accepted for API compatibility. Every new egg goes through the ceremony.
|
||||
* @deprecated No longer changes the flow.
|
||||
*/
|
||||
adoptionOnly?: boolean;
|
||||
}
|
||||
@@ -58,98 +47,20 @@ export function BlobbiOnboardingFlow({
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly = false,
|
||||
existingCompanion,
|
||||
adoptionOnly,
|
||||
}: BlobbiOnboardingFlowProps) {
|
||||
const [showAdoptConfirmDialog, setShowAdoptConfirmDialog] = useState(false);
|
||||
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
coins,
|
||||
} = useBlobbiOnboarding({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('[BlobbiOnboardingFlow] Rendering:', {
|
||||
hasProfile: !!profile,
|
||||
profileName: profile?.name,
|
||||
step: state.step,
|
||||
hasPreview: !!state.preview,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Handle adopt button click - show confirmation dialog
|
||||
const handleAdoptClick = () => {
|
||||
setShowAdoptConfirmDialog(true);
|
||||
};
|
||||
|
||||
// Handle confirm adoption
|
||||
const handleConfirmAdopt = async () => {
|
||||
await actions.adoptPreview();
|
||||
setShowAdoptConfirmDialog(false);
|
||||
};
|
||||
|
||||
// ─── Step: Auto Profile Creation ──────────────────────────────────────────────
|
||||
// Shows a loading state while profile is being auto-created
|
||||
if (state.step === 'creating-profile') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] gap-4 p-8">
|
||||
<Loader2 className="size-10 text-primary animate-spin" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
Setting up your profile...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Adoption Question ──────────────────────────────────────────────────
|
||||
// Shown when profile exists but user has no pets yet
|
||||
if (state.step === 'adoption-question') {
|
||||
return (
|
||||
<BlobbiAdoptionStep
|
||||
blobbonautName={state.blobbonautName || profile?.name}
|
||||
onStartAdoption={actions.startAdoptionPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Egg Preview ────────────────────────────────────────────────────────
|
||||
// Shown when user is previewing/choosing an egg to adopt
|
||||
if (state.step === 'preview' && state.preview) {
|
||||
return (
|
||||
<>
|
||||
<BlobbiEggPreviewCard
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isFirstPreview={state.isFirstPreview}
|
||||
isProcessing={state.isProcessing}
|
||||
actionInProgress={state.actionInProgress === 'reroll' ? 'reroll' : state.actionInProgress === 'adopt' ? 'adopt' : null}
|
||||
onReroll={actions.rerollPreview}
|
||||
onAdopt={handleAdoptClick}
|
||||
onNameChange={actions.setPreviewName}
|
||||
/>
|
||||
|
||||
<BlobbiAdoptionConfirmDialog
|
||||
open={showAdoptConfirmDialog}
|
||||
onOpenChange={setShowAdoptConfirmDialog}
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isAdopting={state.isProcessing && state.actionInProgress === 'adopt'}
|
||||
onConfirm={handleConfirmAdopt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen if parent logic is correct)
|
||||
console.warn('[BlobbiOnboardingFlow] Unexpected state - no matching step');
|
||||
return null;
|
||||
return (
|
||||
<BlobbiHatchingCeremony
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
updateCompanionEvent={updateCompanionEvent}
|
||||
invalidateProfile={invalidateProfile}
|
||||
invalidateCompanion={invalidateCompanion}
|
||||
setStoredSelectedD={setStoredSelectedD}
|
||||
onComplete={onComplete}
|
||||
existingCompanion={existingCompanion}
|
||||
eggOnly={adoptionOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,15 +456,18 @@ export function useBlobbiOnboarding({
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 2. Update profile: deduct coins, add to has, set current_companion
|
||||
// 2. Update profile: deduct coins, add to has list
|
||||
// NOTE: We do NOT set current_companion here because the adopted Blobbi
|
||||
// is still an egg. The companion mechanic only becomes available after hatching.
|
||||
// Eggs should never be auto-assigned as the floating companion.
|
||||
// NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not
|
||||
// complete onboarding. It is set when the first-hatch tour finishes.
|
||||
const newCoins = coins - BLOBBI_ADOPTION_COST;
|
||||
const newHas = [...profile.has, preview.d];
|
||||
|
||||
const profileUpdates: Record<string, string | string[]> = {
|
||||
coins: newCoins.toString(),
|
||||
has: newHas,
|
||||
current_companion: preview.d,
|
||||
onboarding_done: 'true',
|
||||
};
|
||||
|
||||
const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Blobbi Onboarding Module
|
||||
*
|
||||
* Provides components and hooks for the Blobbi onboarding flow:
|
||||
* 1. Auto profile creation (using kind 0 name)
|
||||
* 2. Adoption question
|
||||
* 3. Egg preview with reroll/adopt
|
||||
*
|
||||
* Every new egg goes through the immersive hatching ceremony:
|
||||
* dark screen, huge egg, click-to-hatch, sentimental birth reveal, naming.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { BlobbiAdoptionStep } from './components/BlobbiAdoptionStep';
|
||||
export { BlobbiEggPreviewCard } from './components/BlobbiEggPreviewCard';
|
||||
export { BlobbiAdoptionConfirmDialog } from './components/BlobbiAdoptionConfirmDialog';
|
||||
export { BlobbiOnboardingFlow } from './components/BlobbiOnboardingFlow';
|
||||
export { BlobbiHatchingCeremony } from './components/BlobbiHatchingCeremony';
|
||||
|
||||
// Hooks
|
||||
// Hooks (used internally; kept exported for potential external use)
|
||||
export { useBlobbiOnboarding } from './hooks/useBlobbiOnboarding';
|
||||
export type {
|
||||
OnboardingStep,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg';
|
||||
import { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from '@/blobbi/egg';
|
||||
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
@@ -23,7 +23,7 @@ import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
export type BlobbiEggSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg';
|
||||
export type { EggReactionState, EggStatusEffects, EggTourVisualState } from '@/blobbi/egg';
|
||||
|
||||
export interface BlobbiEggVisualProps {
|
||||
/** The Blobbi companion data from parseBlobbiEvent */
|
||||
@@ -36,6 +36,10 @@ export interface BlobbiEggVisualProps {
|
||||
reaction?: EggReactionState;
|
||||
/** Status effects for egg visual feedback (dirty, sick, happy) */
|
||||
statusEffects?: EggStatusEffects;
|
||||
/** Tour visual state - driven externally by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
@@ -70,6 +74,8 @@ export function BlobbiEggVisual({
|
||||
animated = false,
|
||||
reaction = 'idle',
|
||||
statusEffects,
|
||||
tourVisualState,
|
||||
onTourEggClick,
|
||||
className,
|
||||
}: BlobbiEggVisualProps) {
|
||||
// Memoize adapter output to avoid unnecessary re-renders
|
||||
@@ -103,6 +109,8 @@ export function BlobbiEggVisual({
|
||||
animated={animated && !isSleeping}
|
||||
reaction={effectiveReaction}
|
||||
statusEffects={isSleeping ? undefined : statusEffects}
|
||||
tourVisualState={tourVisualState}
|
||||
onTourEggClick={onTourEggClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+102
-178
@@ -1,50 +1,31 @@
|
||||
/**
|
||||
* BlobbiPhotoModal - Modal for taking and sharing Blobbi photos
|
||||
* BlobbiPhotoModal - Fullscreen photo overlay
|
||||
*
|
||||
* Features:
|
||||
* - Polaroid-style preview of the Blobbi
|
||||
* - Download as PNG
|
||||
* - Post to Nostr with Blossom upload
|
||||
*
|
||||
* Uses html-to-image for DOM-to-PNG conversion.
|
||||
* Simple blurred overlay with the polaroid photo centered,
|
||||
* and download/share buttons below. Tap outside to close.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Download, Send, Loader2, Camera } from 'lucide-react';
|
||||
import { Download, Share2, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BlobbiPolaroidCard } from './BlobbiPolaroidCard';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { trackDailyMissionProgress } from '@/blobbi/actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export interface BlobbiPhotoModalProps {
|
||||
/** Whether the modal is open */
|
||||
open: boolean;
|
||||
/** Callback when the modal should close */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi companion to photograph */
|
||||
companion: BlobbiCompanion;
|
||||
}
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a data URL to a File object
|
||||
*/
|
||||
function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png';
|
||||
@@ -57,218 +38,161 @@ function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
return new File([u8arr], filename, { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download in the browser
|
||||
*/
|
||||
function downloadFile(dataUrl: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPhotoModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
}: BlobbiPhotoModalProps) {
|
||||
const polaroidRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Generate PNG from the polaroid card
|
||||
*/
|
||||
const generateImage = useCallback(async (): Promise<string | null> => {
|
||||
if (!polaroidRef.current) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Could not capture the photo. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!polaroidRef.current) return null;
|
||||
try {
|
||||
// Use html-to-image with high quality settings
|
||||
const dataUrl = await toPng(polaroidRef.current, {
|
||||
return await toPng(polaroidRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // 2x for retina displays
|
||||
pixelRatio: 2,
|
||||
cacheBust: true,
|
||||
// Skip external fonts that might fail to load
|
||||
skipFonts: true,
|
||||
});
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to generate image:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to generate the photo. Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to generate image:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Failed to capture photo.' });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle download action
|
||||
*/
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const dataUrl = await generateImage();
|
||||
if (dataUrl) {
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`;
|
||||
downloadFile(dataUrl, filename);
|
||||
toast({
|
||||
title: 'Photo saved!',
|
||||
description: 'Your Blobbi photo has been downloaded.',
|
||||
});
|
||||
if (!dataUrl) return;
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-photo.png`;
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// On native, use the download utility which handles share sheet
|
||||
const blob = dataUrlToFile(dataUrl, filename);
|
||||
const url = URL.createObjectURL(blob);
|
||||
await openUrl(url);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
toast({ title: 'Photo saved!' });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [generateImage, companion.name]);
|
||||
|
||||
/**
|
||||
* Handle post action - upload to Blossom and create Nostr post
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to post your Blobbi photo.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPosting(true);
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
// Generate the image
|
||||
const dataUrl = await generateImage();
|
||||
if (!dataUrl) {
|
||||
return;
|
||||
}
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Convert to File for upload
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`;
|
||||
const file = dataUrlToFile(dataUrl, filename);
|
||||
|
||||
// Upload to Blossom - returns NIP-94 compatible tags
|
||||
const tags = await uploadFile(file);
|
||||
|
||||
// Extract URL from the 'url' tag (NIP-94 format)
|
||||
// The upload hook returns tags like [['url', '...'], ['m', '...'], ['x', '...'], ...]
|
||||
const urlTag = tags.find((tag) => tag[0] === 'url');
|
||||
if (!urlTag || !urlTag[1]) {
|
||||
throw new Error('Upload succeeded but no URL was returned');
|
||||
}
|
||||
if (!urlTag?.[1]) throw new Error('Upload succeeded but no URL returned');
|
||||
const url = urlTag[1];
|
||||
|
||||
// Build imeta tag from all NIP-94 tags
|
||||
// Format: ['imeta', 'url https://...', 'm image/png', 'x abc123', ...]
|
||||
const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`);
|
||||
|
||||
// Create the post content
|
||||
const content = `${companion.name} ${url}`;
|
||||
|
||||
// Publish kind 1 event
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
content: `${companion.name} ${url}`,
|
||||
tags: [['imeta', ...imetaFields]],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Posted!',
|
||||
description: 'Your Blobbi photo has been shared.',
|
||||
});
|
||||
|
||||
// Track daily mission progress for photo action
|
||||
toast({ title: 'Posted!', description: 'Your Blobbi photo has been shared.' });
|
||||
trackDailyMissionProgress('take_photo', 1, user.pubkey);
|
||||
|
||||
// Close the modal after successful post
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to post:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to share:', error);
|
||||
toast({ variant: 'destructive', title: 'Failed to post', description: error instanceof Error ? error.message : 'Please try again.' });
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]);
|
||||
|
||||
const isProcessing = isGenerating || isPosting;
|
||||
if (!open) return null;
|
||||
|
||||
const isProcessing = isDownloading || isSharing;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Camera className="size-5" />
|
||||
Take a Photo
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Capture a polaroid-style photo of {companion.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||
{/* Backdrop — tap to close */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/60 backdrop-blur-sm"
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Polaroid preview - centered */}
|
||||
<div className="flex justify-center py-4">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
{/* Close button — top-right of the container */}
|
||||
<button
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
className="absolute top-3 right-3 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
{/* Polaroid card */}
|
||||
<div className="relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative z-10 flex items-center gap-6 mt-8">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-sky-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #0ea5e9 25%, transparent), color-mix(in srgb, #0ea5e9 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isDownloading ? <Loader2 className="size-6 animate-spin" /> : <Download className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Save</span>
|
||||
</button>
|
||||
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4 mr-2" />
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={isProcessing || !user}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPosting ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4 mr-2" />
|
||||
)}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login hint if not logged in */}
|
||||
{!user && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Log in to post your Blobbi photo
|
||||
</p>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-violet-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #8b5cf6 25%, transparent), color-mix(in srgb, #8b5cf6 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isSharing ? <Loader2 className="size-6 animate-spin" /> : <Share2 className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Post</span>
|
||||
</button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects } from './BlobbiEggVisual';
|
||||
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects, type EggTourVisualState } from './BlobbiEggVisual';
|
||||
import { BlobbiBabyVisual } from './BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from './BlobbiAdultVisual';
|
||||
import { FloatingMusicNotes } from './FloatingMusicNotes';
|
||||
@@ -50,6 +50,10 @@ export interface BlobbiStageVisualProps {
|
||||
* Status-reaction body effects are already in the recipe.
|
||||
*/
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Tour visual state for egg stage - driven by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -74,6 +78,8 @@ export function BlobbiStageVisual({
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
tourVisualState,
|
||||
onTourEggClick,
|
||||
className,
|
||||
}: BlobbiStageVisualProps) {
|
||||
const { stage } = companion;
|
||||
@@ -109,6 +115,8 @@ export function BlobbiStageVisual({
|
||||
animated={animated}
|
||||
reaction={effectiveReaction}
|
||||
statusEffects={eggStatusEffects}
|
||||
tourVisualState={tourVisualState}
|
||||
onTourEggClick={onTourEggClick}
|
||||
className="size-full"
|
||||
/>
|
||||
<FloatingMusicNotes active={showMusicNotes} />
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* ActionBarEditor - Lightweight modal for customizing the bottom action bar.
|
||||
*
|
||||
* Rules:
|
||||
* - Main Action + More are fixed (always shown, not editable)
|
||||
* - Up to 3 custom visible slots
|
||||
* - User can toggle visibility, reorder, and highlight one item
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Star,
|
||||
Egg,
|
||||
Target,
|
||||
Package,
|
||||
Camera,
|
||||
Footprints,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
type ActionBarPreferences,
|
||||
type BarItemId,
|
||||
BAR_ITEM_LABELS,
|
||||
MAX_VISIBLE_SLOTS,
|
||||
toggleSlotVisibility,
|
||||
toggleSlotHighlight,
|
||||
moveSlotUp,
|
||||
moveSlotDown,
|
||||
visibleCount,
|
||||
DEFAULT_PREFERENCES,
|
||||
} from '../lib/action-bar-preferences';
|
||||
|
||||
// ─── Icon Mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BAR_ITEM_ICONS: Record<BarItemId, React.ReactNode> = {
|
||||
blobbies: <Egg className="size-4" />,
|
||||
missions: <Target className="size-4" />,
|
||||
items: <Package className="size-4" />,
|
||||
take_photo: <Camera className="size-4" />,
|
||||
set_companion: <Footprints className="size-4" />,
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ActionBarEditorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
preferences: ActionBarPreferences;
|
||||
onUpdate: (prefs: ActionBarPreferences) => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ActionBarEditor({
|
||||
open,
|
||||
onOpenChange,
|
||||
preferences,
|
||||
onUpdate,
|
||||
}: ActionBarEditorProps) {
|
||||
const currentVisible = visibleCount(preferences);
|
||||
const atMax = currentVisible >= MAX_VISIBLE_SLOTS;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: BarItemId) => onUpdate(toggleSlotVisibility(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleHighlight = useCallback(
|
||||
(id: BarItemId) => onUpdate(toggleSlotHighlight(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleUp = useCallback(
|
||||
(id: BarItemId) => onUpdate(moveSlotUp(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleDown = useCallback(
|
||||
(id: BarItemId) => onUpdate(moveSlotDown(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => onUpdate(DEFAULT_PREFERENCES),
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">Edit Action Bar</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Choose up to {MAX_VISIBLE_SLOTS} items. Main Action and More are always shown.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1 py-2">
|
||||
{preferences.slots.map((slot, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === preferences.slots.length - 1;
|
||||
const canTurnOn = slot.visible || !atMax;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 transition-colors',
|
||||
slot.visible
|
||||
? 'bg-accent/60'
|
||||
: 'bg-muted/30 opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Icon + Label */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{BAR_ITEM_ICONS[slot.id]}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{BAR_ITEM_LABELS[slot.id]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Highlight toggle */}
|
||||
{slot.visible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', slot.highlighted && 'text-amber-500')}
|
||||
onClick={() => handleHighlight(slot.id)}
|
||||
title={slot.highlighted ? 'Remove highlight' : 'Highlight'}
|
||||
>
|
||||
<Star className={cn('size-3.5', slot.highlighted && 'fill-current')} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Reorder controls */}
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
disabled={isFirst}
|
||||
onClick={() => handleUp(slot.id)}
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
disabled={isLast}
|
||||
onClick={() => handleDown(slot.id)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
disabled={!canTurnOn && !slot.visible}
|
||||
onClick={() => handleToggle(slot.id)}
|
||||
title={slot.visible ? 'Hide' : 'Show'}
|
||||
>
|
||||
{slot.visible ? (
|
||||
<Eye className="size-3.5" />
|
||||
) : (
|
||||
<EyeOff className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Slot counter + reset */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentVisible}/{MAX_VISIBLE_SLOTS} slots used
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* MissionSurfaceCard - Compact inline card that surfaces ONE relevant
|
||||
* mission/task at a time below the Blobbi visual.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Hatch / Evolve tasks (lifecycle progression)
|
||||
* 2. Daily missions (engagement / coin loop)
|
||||
*
|
||||
* Carousel:
|
||||
* - Auto-rotates every ~5s when > 1 card available
|
||||
* - Manual tap cycles to the next card
|
||||
* - Auto-advances when the current card's mission completes
|
||||
* - Single card = no rotation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Target,
|
||||
ChevronRight,
|
||||
Egg,
|
||||
Sparkles,
|
||||
Coins,
|
||||
CircleDot,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
|
||||
|
||||
// ─── Card Item Types ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TaskCardItem {
|
||||
kind: 'task';
|
||||
badge: 'Hatch' | 'Evolve';
|
||||
title: string;
|
||||
description: string;
|
||||
progress: number; // 0-100
|
||||
progressLabel: string;
|
||||
}
|
||||
|
||||
interface DailyCardItem {
|
||||
kind: 'daily';
|
||||
badge: 'Daily';
|
||||
title: string;
|
||||
description: string;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
reward: number;
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
type CardItem = TaskCardItem | DailyCardItem;
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MissionSurfaceCardProps {
|
||||
/** Hatch or evolve tasks (from useActiveTaskProcess) */
|
||||
tasks: HatchTask[];
|
||||
/** Whether a task process (incubating/evolving) is active */
|
||||
isInTaskProcess: boolean;
|
||||
/** Process type for badge label */
|
||||
processType: 'hatch' | 'evolve' | null;
|
||||
/** Daily missions */
|
||||
dailyMissions: DailyMission[];
|
||||
/** Called when user taps "View all" */
|
||||
onViewAll: () => void;
|
||||
/** Called when user dismisses the card */
|
||||
onHide?: () => void;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildTaskCards(
|
||||
tasks: HatchTask[],
|
||||
processType: 'hatch' | 'evolve' | null,
|
||||
): TaskCardItem[] {
|
||||
if (!processType) return [];
|
||||
const badge = processType === 'hatch' ? 'Hatch' : 'Evolve';
|
||||
|
||||
// Show only incomplete tasks, or the first completed one if all are done
|
||||
const incomplete = tasks.filter((t) => !t.completed);
|
||||
const toShow = incomplete.length > 0 ? incomplete : tasks.slice(0, 1);
|
||||
|
||||
return toShow.map((t) => ({
|
||||
kind: 'task',
|
||||
badge: badge as 'Hatch' | 'Evolve',
|
||||
title: t.name,
|
||||
description: t.description,
|
||||
progress: t.required > 0 ? Math.min(100, Math.round((t.current / t.required) * 100)) : 0,
|
||||
progressLabel: `${t.current}/${t.required}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDailyCards(missions: DailyMission[]): DailyCardItem[] {
|
||||
// Show unclaimed missions first, then claimed ones
|
||||
const unclaimed = missions.filter((m) => !m.claimed);
|
||||
const toShow = unclaimed.length > 0 ? unclaimed : [];
|
||||
|
||||
return toShow.map((m) => ({
|
||||
kind: 'daily',
|
||||
badge: 'Daily',
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
progress: m.requiredCount > 0
|
||||
? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100))
|
||||
: 0,
|
||||
progressLabel: `${m.currentCount}/${m.requiredCount}`,
|
||||
reward: m.reward,
|
||||
claimed: m.claimed,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Auto-rotate interval ─────────────────────────────────────────────────────
|
||||
const ROTATE_INTERVAL_MS = 5000;
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function MissionSurfaceCard({
|
||||
tasks,
|
||||
isInTaskProcess,
|
||||
processType,
|
||||
dailyMissions,
|
||||
onViewAll,
|
||||
onHide,
|
||||
className,
|
||||
}: MissionSurfaceCardProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Build card list: tasks first (priority), then daily
|
||||
const cards = useMemo<CardItem[]>(() => {
|
||||
const taskCards = isInTaskProcess ? buildTaskCards(tasks, processType) : [];
|
||||
const dailyCards = buildDailyCards(dailyMissions);
|
||||
return [...taskCards, ...dailyCards];
|
||||
}, [tasks, isInTaskProcess, processType, dailyMissions]);
|
||||
|
||||
// Clamp index if cards shrink
|
||||
useEffect(() => {
|
||||
if (activeIndex >= cards.length && cards.length > 0) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [cards.length, activeIndex]);
|
||||
|
||||
// Auto-rotate (only when > 1 card)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cards.length <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % cards.length);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [cards.length]);
|
||||
|
||||
// Manual cycle
|
||||
const handleCycle = useCallback(() => {
|
||||
if (cards.length <= 1) return;
|
||||
// Reset auto-rotate timer
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % cards.length);
|
||||
setIsAnimating(false);
|
||||
// Restart timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % cards.length);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
}, 150);
|
||||
}, [cards.length]);
|
||||
|
||||
// Nothing to show
|
||||
if (cards.length === 0) return null;
|
||||
|
||||
const card = cards[Math.min(activeIndex, cards.length - 1)];
|
||||
|
||||
const badgeColor =
|
||||
card.badge === 'Hatch'
|
||||
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
|
||||
: card.badge === 'Evolve'
|
||||
? 'bg-purple-500/15 text-purple-600 dark:text-purple-400'
|
||||
: 'bg-primary/10 text-primary';
|
||||
|
||||
const badgeIcon =
|
||||
card.badge === 'Hatch' ? (
|
||||
<Egg className="size-3" />
|
||||
) : card.badge === 'Evolve' ? (
|
||||
<Sparkles className="size-3" />
|
||||
) : (
|
||||
<Target className="size-3" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<button
|
||||
onClick={handleCycle}
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border border-border/60 bg-card/80 backdrop-blur-sm',
|
||||
'px-3.5 py-2.5 transition-all duration-200',
|
||||
'hover:bg-accent/40 active:scale-[0.99]',
|
||||
isAnimating && 'opacity-0 translate-x-2',
|
||||
!isAnimating && 'opacity-100 translate-x-0',
|
||||
)}
|
||||
>
|
||||
{/* Top row: badge + title + view all */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-[10px] font-medium px-1.5 py-0 h-4 gap-1', badgeColor)}
|
||||
>
|
||||
{badgeIcon}
|
||||
{card.badge}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{card.title}
|
||||
</span>
|
||||
{/* Dot indicators when multiple cards */}
|
||||
{cards.length > 1 && (
|
||||
<div className="flex gap-0.5 items-center shrink-0">
|
||||
{cards.map((_, i) => (
|
||||
<CircleDot
|
||||
key={i}
|
||||
className={cn(
|
||||
'size-2 transition-colors',
|
||||
i === activeIndex
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground/30',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Dismiss button */}
|
||||
{onHide && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onHide();
|
||||
}}
|
||||
className="shrink-0 p-0.5 -m-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
title="Hide mission card"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground mb-2 line-clamp-1">
|
||||
{card.description}
|
||||
</p>
|
||||
|
||||
{/* Bottom row: progress bar + label + reward/view all */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={card.progress}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
|
||||
{card.progressLabel}
|
||||
</span>
|
||||
{card.kind === 'daily' && !card.claimed && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium shrink-0">
|
||||
<Coins className="size-2.5" />
|
||||
{card.reward}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* View all link */}
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="flex items-center gap-1 mx-auto mt-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View all missions
|
||||
<ChevronRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Action Bar Preferences
|
||||
*
|
||||
* Lightweight localStorage-backed model controlling which items are
|
||||
* visible in the BlobbiBottomBar and in which order.
|
||||
*
|
||||
* Fixed items (cannot be hidden or reordered by the user):
|
||||
* - Main Action (center button) -- always present
|
||||
* - More (right-most button) -- always present
|
||||
*
|
||||
* Customizable items (up to 3 visible slots):
|
||||
* Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion
|
||||
*
|
||||
* Persistence: localStorage only for now. Shape is designed so it can
|
||||
* later migrate to a Nostr event tag.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Identifiers for customizable bottom-bar items */
|
||||
export type BarItemId =
|
||||
| 'blobbies'
|
||||
| 'missions'
|
||||
| 'items'
|
||||
| 'take_photo'
|
||||
| 'set_companion';
|
||||
|
||||
/** A single customizable bar slot */
|
||||
export interface BarItemSlot {
|
||||
id: BarItemId;
|
||||
visible: boolean;
|
||||
/** If true, this item receives a subtle highlight ring in the bar */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
/** Full persisted preference shape */
|
||||
export interface ActionBarPreferences {
|
||||
/** Ordered list of customizable items. Visible items render in array order. */
|
||||
slots: BarItemSlot[];
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Max visible customizable items (Main Action + More are fixed) */
|
||||
export const MAX_VISIBLE_SLOTS = 3;
|
||||
|
||||
/** localStorage key for bar slot preferences */
|
||||
export const STORAGE_KEY = 'blobbi:action-bar-prefs';
|
||||
|
||||
/** localStorage key for inline mission surface card visibility */
|
||||
export const MISSION_CARD_STORAGE_KEY = 'blobbi:mission-card-visible';
|
||||
|
||||
/** Human-readable labels */
|
||||
export const BAR_ITEM_LABELS: Record<BarItemId, string> = {
|
||||
blobbies: 'Blobbies',
|
||||
missions: 'Missions',
|
||||
items: 'Items',
|
||||
take_photo: 'Take Photo',
|
||||
set_companion: 'Companion',
|
||||
};
|
||||
|
||||
/** Default preferences: only Blobbies visible, others hidden */
|
||||
export const DEFAULT_PREFERENCES: ActionBarPreferences = {
|
||||
slots: [
|
||||
{ id: 'blobbies', visible: true },
|
||||
{ id: 'missions', visible: false },
|
||||
{ id: 'items', visible: false },
|
||||
{ id: 'take_photo', visible: false },
|
||||
{ id: 'set_companion', visible: false },
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return only visible slots, in order */
|
||||
export function getVisibleSlots(prefs: ActionBarPreferences): BarItemSlot[] {
|
||||
return prefs.slots.filter((s) => s.visible);
|
||||
}
|
||||
|
||||
/** Count of currently visible custom items */
|
||||
export function visibleCount(prefs: ActionBarPreferences): number {
|
||||
return prefs.slots.filter((s) => s.visible).length;
|
||||
}
|
||||
|
||||
/** Can we show one more item? */
|
||||
export function canShowMore(prefs: ActionBarPreferences): boolean {
|
||||
return visibleCount(prefs) < MAX_VISIBLE_SLOTS;
|
||||
}
|
||||
|
||||
/** Toggle visibility of a slot. Enforces MAX_VISIBLE_SLOTS. */
|
||||
export function toggleSlotVisibility(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
const slot = prefs.slots.find((s) => s.id === id);
|
||||
if (!slot) return prefs;
|
||||
|
||||
// If turning ON and already at max, reject
|
||||
if (!slot.visible && !canShowMore(prefs)) return prefs;
|
||||
|
||||
return {
|
||||
slots: prefs.slots.map((s) =>
|
||||
s.id === id ? { ...s, visible: !s.visible } : s,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Toggle highlight on a slot (only one can be highlighted at a time) */
|
||||
export function toggleSlotHighlight(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
return {
|
||||
slots: prefs.slots.map((s) =>
|
||||
s.id === id
|
||||
? { ...s, highlighted: !s.highlighted }
|
||||
: { ...s, highlighted: false },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Move a slot up (earlier) in the list */
|
||||
export function moveSlotUp(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
const idx = prefs.slots.findIndex((s) => s.id === id);
|
||||
if (idx <= 0) return prefs;
|
||||
const newSlots = [...prefs.slots];
|
||||
[newSlots[idx - 1], newSlots[idx]] = [newSlots[idx], newSlots[idx - 1]];
|
||||
return { slots: newSlots };
|
||||
}
|
||||
|
||||
/** Move a slot down (later) in the list */
|
||||
export function moveSlotDown(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
const idx = prefs.slots.findIndex((s) => s.id === id);
|
||||
if (idx < 0 || idx >= prefs.slots.length - 1) return prefs;
|
||||
const newSlots = [...prefs.slots];
|
||||
[newSlots[idx], newSlots[idx + 1]] = [newSlots[idx + 1], newSlots[idx]];
|
||||
return { slots: newSlots };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and repair preferences loaded from localStorage.
|
||||
* Adds missing candidates, removes unknown ids, preserves order.
|
||||
*/
|
||||
export function validatePreferences(raw: unknown): ActionBarPreferences {
|
||||
if (!raw || typeof raw !== 'object' || !('slots' in raw)) {
|
||||
return DEFAULT_PREFERENCES;
|
||||
}
|
||||
|
||||
const obj = raw as { slots: unknown };
|
||||
if (!Array.isArray(obj.slots)) return DEFAULT_PREFERENCES;
|
||||
|
||||
const knownIds = new Set<BarItemId>(DEFAULT_PREFERENCES.slots.map((s) => s.id));
|
||||
const seenIds = new Set<BarItemId>();
|
||||
|
||||
// Keep valid existing entries
|
||||
const cleaned: BarItemSlot[] = [];
|
||||
for (const item of obj.slots) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'id' in item &&
|
||||
typeof (item as BarItemSlot).id === 'string' &&
|
||||
knownIds.has((item as BarItemSlot).id) &&
|
||||
!seenIds.has((item as BarItemSlot).id)
|
||||
) {
|
||||
const slot = item as BarItemSlot;
|
||||
seenIds.add(slot.id);
|
||||
cleaned.push({
|
||||
id: slot.id,
|
||||
visible: typeof slot.visible === 'boolean' ? slot.visible : false,
|
||||
highlighted: typeof slot.highlighted === 'boolean' ? slot.highlighted : false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add any missing candidates (new features added after user saved prefs)
|
||||
for (const def of DEFAULT_PREFERENCES.slots) {
|
||||
if (!seenIds.has(def.id)) {
|
||||
cleaned.push({ ...def });
|
||||
}
|
||||
}
|
||||
|
||||
return { slots: cleaned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preferences from localStorage with validation.
|
||||
*/
|
||||
export function loadPreferences(): ActionBarPreferences {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_PREFERENCES;
|
||||
return validatePreferences(JSON.parse(raw));
|
||||
} catch {
|
||||
return DEFAULT_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save preferences to localStorage.
|
||||
*/
|
||||
export function savePreferences(prefs: ActionBarPreferences): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// Silently fail (quota, SSR, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mission Surface Card Visibility ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load the inline mission card visibility preference.
|
||||
* Defaults to `true` (visible).
|
||||
*/
|
||||
export function loadMissionCardVisible(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(MISSION_CARD_STORAGE_KEY);
|
||||
if (raw === null) return true;
|
||||
return raw === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the inline mission card visibility preference.
|
||||
*/
|
||||
export function saveMissionCardVisible(visible: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(MISSION_CARD_STORAGE_KEY, String(visible));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Visible-in-bar Set Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the set of BarItemIds currently visible in the bottom bar.
|
||||
* Used by the More dropdown to skip items that are already in the bar.
|
||||
*/
|
||||
export function getVisibleBarIds(prefs: ActionBarPreferences): Set<BarItemId> {
|
||||
return new Set(prefs.slots.filter((s) => s.visible).map((s) => s.id));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi {
|
||||
size: companion.visualTraits.size,
|
||||
// Metadata
|
||||
seed: companion.seed,
|
||||
tags: companion.allTags,
|
||||
tags: companion.allTags ?? [],
|
||||
// Adult-specific data (for adult form resolution)
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { ExternalLink, GitFork, Package } from 'lucide-react';
|
||||
import { ExternalLink, GitFork, Package, Play } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { NsitePreviewDialog } from '@/components/NsitePreviewDialog';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { NostrURI } from '@/lib/NostrURI';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -66,6 +67,31 @@ function getShakespeareUrl(tags: string[][]): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
interface NsiteRef {
|
||||
/** The author pubkey (hex) of the kind 35128 event. */
|
||||
pubkey: string;
|
||||
/** The d-tag identifier of the kind 35128 event. */
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract nsite info from a kind 35128 `a` tag, if present.
|
||||
* The `a` tag value format is `"35128:<pubkey>:<d-tag>"`.
|
||||
*/
|
||||
function getNsiteRef(tags: string[][]): NsiteRef | undefined {
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'a') continue;
|
||||
const parts = tag[1]?.split(':');
|
||||
if (!parts || parts[0] !== '35128' || parts.length < 3) continue;
|
||||
const pubkey = parts[1];
|
||||
const identifier = parts.slice(2).join(':');
|
||||
if (!pubkey || !identifier) continue;
|
||||
return { pubkey, identifier };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface AppHandlerContentProps {
|
||||
event: NostrEvent;
|
||||
/** If true, show compact preview (used in NoteCard feed). */
|
||||
@@ -79,42 +105,40 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
|
||||
const about = metadata.about;
|
||||
const picture = metadata.picture;
|
||||
const banner = metadata.banner;
|
||||
const websiteUrl = getWebsiteUrl(event.tags, metadata);
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
|
||||
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
|
||||
const nsiteRef = useMemo(() => getNsiteRef(event.tags), [event.tags]);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
|
||||
const thumbnailUrl = preview?.thumbnail_url;
|
||||
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const showThumbnail = thumbnailUrl && !imgError;
|
||||
// Fetch the actual nsite event so we can serve files directly from Blossom.
|
||||
const { data: nsiteEvent } = useAddrEvent(
|
||||
nsiteRef ? { kind: 35128, pubkey: nsiteRef.pubkey, identifier: nsiteRef.identifier } : undefined,
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2">
|
||||
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
|
||||
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
|
||||
{(previewLoading || showThumbnail) && (
|
||||
{/* Banner hero */}
|
||||
{banner && (
|
||||
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
|
||||
{previewLoading ? (
|
||||
<Skeleton className="absolute inset-0" />
|
||||
) : (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={banner}
|
||||
alt=""
|
||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 px-3.5 pb-3.5 space-y-2">
|
||||
{/* App icon — overlaps the screenshot hero like a profile avatar */}
|
||||
<div className={showThumbnail || previewLoading ? '-mt-7' : 'pt-3.5'}>
|
||||
<div className="relative px-3.5 pb-3.5 space-y-2">
|
||||
{/* App icon — overlaps the banner hero like a profile avatar */}
|
||||
<div className={banner ? '-mt-7' : 'pt-3.5'}>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
@@ -166,8 +190,19 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{nsiteRef && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!nsiteEvent}
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
Run
|
||||
</Button>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<Button asChild size="sm" className="h-7 text-xs">
|
||||
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'} className="h-7 text-xs">
|
||||
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Open App
|
||||
<ExternalLink className="size-3 ml-1.5" />
|
||||
@@ -186,36 +221,42 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{nsiteRef && nsiteEvent && (
|
||||
<NsitePreviewDialog
|
||||
event={nsiteEvent}
|
||||
appName={name}
|
||||
appPicture={picture}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Full detail view
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
|
||||
{(previewLoading || showThumbnail) && (
|
||||
{/* Banner hero */}
|
||||
{banner && (
|
||||
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
|
||||
{previewLoading ? (
|
||||
<Skeleton className="absolute inset-0" />
|
||||
) : (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={banner}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 px-4 pb-4 space-y-3">
|
||||
{/* App icon — overlaps the screenshot hero like a profile avatar */}
|
||||
<div className="relative px-4 pb-4 space-y-3">
|
||||
{/* App icon — overlaps the banner hero like a profile avatar */}
|
||||
<div className={cn(
|
||||
'flex items-end justify-between',
|
||||
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
|
||||
banner ? '-mt-10' : 'pt-4',
|
||||
)}>
|
||||
{picture ? (
|
||||
<img
|
||||
@@ -268,8 +309,18 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{nsiteRef && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!nsiteEvent}
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Run
|
||||
</Button>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<Button asChild size="sm">
|
||||
<Button asChild size="sm" variant={nsiteRef ? 'secondary' : 'default'}>
|
||||
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Open App
|
||||
<ExternalLink className="size-3 ml-1.5" />
|
||||
@@ -287,6 +338,16 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nsiteRef && nsiteEvent && (
|
||||
<NsitePreviewDialog
|
||||
event={nsiteEvent}
|
||||
appName={name}
|
||||
appPicture={picture}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
|
||||
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
|
||||
Users, Zap,
|
||||
Users, Vote, Zap,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
|
||||
import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBookInfo } from '@/hooks/useBookInfo';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
@@ -44,26 +45,38 @@ interface CommentRoot {
|
||||
identifier?: string;
|
||||
/** Root kind number (from K tag). */
|
||||
rootKind?: string;
|
||||
/** Relay URL hint from the E or A tag (position [2]). */
|
||||
relayHint?: string;
|
||||
/** Author pubkey hint extracted from the E tag (position [3]) or P tag. */
|
||||
authorHint?: string;
|
||||
}
|
||||
|
||||
/** Parse the root reference from a kind 1111 comment's tags. */
|
||||
function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
|
||||
const aTag = event.tags.find(([name]) => name === 'A')?.[1];
|
||||
const aTagFull = event.tags.find(([name]) => name === 'A');
|
||||
// Use find (not findLast) to get the root E tag, not a parent e tag
|
||||
const eTag = event.tags.find(([name]) => name === 'E')?.[1];
|
||||
const eTagFull = event.tags.find(([name]) => name === 'E');
|
||||
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
|
||||
const kTag = event.tags.find(([name]) => name === 'K')?.[1];
|
||||
// P tag holds the root event author's pubkey — used as author hint fallback
|
||||
const pTag = event.tags.find(([name]) => name === 'P')?.[1];
|
||||
|
||||
if (aTag) {
|
||||
if (aTagFull) {
|
||||
const aTag = aTagFull[1];
|
||||
const relayHint = aTagFull[2] || undefined;
|
||||
const parts = aTag.split(':');
|
||||
const kind = parseInt(parts[0], 10);
|
||||
const pubkey = parts[1] ?? '';
|
||||
const identifier = parts.slice(2).join(':');
|
||||
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag };
|
||||
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag, relayHint };
|
||||
}
|
||||
|
||||
if (eTag) {
|
||||
return { type: 'event', eventId: eTag, rootKind: kTag };
|
||||
if (eTagFull) {
|
||||
const eTag = eTagFull[1];
|
||||
const relayHint = eTagFull[2] || undefined;
|
||||
// NIP-22 E tags may have the author pubkey at position [3]; fall back to P tag
|
||||
const authorHint = eTagFull[3] || pTag || undefined;
|
||||
return { type: 'event', eventId: eTag, rootKind: kTag, relayHint, authorHint };
|
||||
}
|
||||
|
||||
if (iTag) {
|
||||
@@ -91,6 +104,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
22: 'a short video',
|
||||
62: 'a request to vanish',
|
||||
1063: 'a file',
|
||||
1018: 'a vote',
|
||||
1068: 'a poll',
|
||||
1111: 'a comment',
|
||||
1222: 'a voice message',
|
||||
@@ -108,7 +122,8 @@ const KIND_LABELS: Record<number, string> = {
|
||||
30030: 'an emoji pack',
|
||||
30054: 'a podcast episode',
|
||||
30055: 'a podcast trailer',
|
||||
30063: 'a release',
|
||||
3063: 'a Zapstore asset',
|
||||
30063: 'a Zapstore release',
|
||||
30311: 'a stream',
|
||||
30315: 'a status',
|
||||
30617: 'a repository',
|
||||
@@ -116,7 +131,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
31922: 'a calendar event',
|
||||
31923: 'a calendar event',
|
||||
31990: 'an app',
|
||||
32267: 'an app',
|
||||
32267: 'a Zapstore app',
|
||||
34139: 'a playlist',
|
||||
34236: 'a divine',
|
||||
34550: 'a community',
|
||||
@@ -141,6 +156,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
21: Film,
|
||||
22: Film,
|
||||
1063: FileText,
|
||||
1018: Vote,
|
||||
1068: BarChart3,
|
||||
1222: Mic,
|
||||
1617: FileText,
|
||||
@@ -155,6 +171,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
30030: SmilePlus,
|
||||
30054: Podcast,
|
||||
30055: Podcast,
|
||||
3063: Package,
|
||||
30063: Package,
|
||||
30311: Radio,
|
||||
30617: GitBranch,
|
||||
@@ -212,10 +229,11 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
34139: 'playlist',
|
||||
};
|
||||
|
||||
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */
|
||||
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto Zapstore app"). */
|
||||
const KIND_POSTFIXES: Partial<Record<number, string>> = {
|
||||
32267: 'on Zapstore',
|
||||
30063: 'release',
|
||||
30063: 'Zapstore release',
|
||||
3063: 'Zapstore asset',
|
||||
};
|
||||
|
||||
/** Get a display name for an event based on its kind and tags. */
|
||||
@@ -485,7 +503,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
|
||||
|
||||
/** Comment context for non-profile addressable event roots (A tag). */
|
||||
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
const { data: event, isLoading } = useAddrEvent(root.addr);
|
||||
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
|
||||
|
||||
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
|
||||
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
|
||||
@@ -523,18 +541,33 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
|
||||
|
||||
/** Comment context for regular event roots (E tag). */
|
||||
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
const { data: event, isLoading } = useEvent(root.eventId);
|
||||
const { data: event, isLoading } = useEvent(
|
||||
root.eventId,
|
||||
root.relayHint ? [root.relayHint] : undefined,
|
||||
root.authorHint,
|
||||
);
|
||||
|
||||
// Kind 7 reactions get special treatment
|
||||
if (event?.kind === 7) {
|
||||
return <ReactionCommentContext event={event} className={className} />;
|
||||
}
|
||||
|
||||
// Kind 1018 poll votes get special treatment
|
||||
if (event?.kind === 1018) {
|
||||
return <PollVoteCommentContext event={event} className={className} />;
|
||||
}
|
||||
|
||||
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
|
||||
const link = event ? getRootLink(event) : undefined;
|
||||
|
||||
const hoverContent = root.eventId ? (
|
||||
<EmbeddedNote eventId={root.eventId} className="border-0 rounded-none" disableHoverCards />
|
||||
<EmbeddedNote
|
||||
eventId={root.eventId}
|
||||
relays={root.relayHint ? [root.relayHint] : undefined}
|
||||
authorHint={root.authorHint}
|
||||
className="border-0 rounded-none"
|
||||
disableHoverCards
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
@@ -583,6 +616,43 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
|
||||
);
|
||||
}
|
||||
|
||||
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
|
||||
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const voteLink = getRootLink(event);
|
||||
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
|
||||
|
||||
const voteLabel = usePollVoteLabel(event);
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className}>
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3.5 w-16 inline-block" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileLink}
|
||||
className="text-primary hover:underline truncate cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<Link
|
||||
to={voteLink}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Vote className="size-3.5 shrink-0" />
|
||||
{voteLabel ? `vote for ${voteLabel}` : 'vote'}
|
||||
</Link>
|
||||
</CommentContextRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Comment context for external content roots (I tag). */
|
||||
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
const identifier = root.identifier ?? '';
|
||||
|
||||
@@ -20,6 +20,7 @@ import { GifPicker } from '@/components/GifPicker';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
@@ -201,7 +202,6 @@ export function ComposeBox({
|
||||
|
||||
// Poll mode state
|
||||
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
|
||||
const [pollQuestion, setPollQuestion] = useState('');
|
||||
const [pollOptions, setPollOptions] = useState([
|
||||
{ id: pollOptionId(), label: '' },
|
||||
{ id: pollOptionId(), label: '' },
|
||||
@@ -233,7 +233,6 @@ export function ComposeBox({
|
||||
setTrayOpen(false);
|
||||
setInternalPreviewMode(false);
|
||||
setMode(initialMode);
|
||||
setPollQuestion('');
|
||||
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
|
||||
setPollType('singlechoice');
|
||||
setPollDuration(7);
|
||||
@@ -982,7 +981,8 @@ export function ComposeBox({
|
||||
|
||||
const handlePollSubmit = async () => {
|
||||
const filledOptions = pollOptions.filter((o) => o.label.trim());
|
||||
if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return;
|
||||
const finalContent = content.trim();
|
||||
if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return;
|
||||
|
||||
const tags: string[][] = [];
|
||||
for (const opt of filledOptions) {
|
||||
@@ -992,10 +992,27 @@ export function ComposeBox({
|
||||
if (pollDuration > 0) {
|
||||
tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]);
|
||||
}
|
||||
tags.push(['alt', `Poll: ${pollQuestion.trim()}`]);
|
||||
|
||||
// NIP-92: Add imeta tags for media URLs in content
|
||||
const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'));
|
||||
const processedUrls = new Set<string>();
|
||||
for (const match of mediaUrlMatches) {
|
||||
const url = match[0];
|
||||
if (processedUrls.has(url)) continue;
|
||||
processedUrls.add(url);
|
||||
const fileTags = uploadedFileGroups.get(url);
|
||||
if (fileTags) {
|
||||
tags.push(['imeta', ...fileTags.map(tag => `${tag[0]} ${tag[1]}`)]);
|
||||
} else {
|
||||
const ext = match[1].toLowerCase();
|
||||
tags.push(['imeta', `url ${url}`, `m ${mimeFromExt(ext)}`]);
|
||||
}
|
||||
}
|
||||
|
||||
tags.push(['alt', `Poll: ${finalContent}`]);
|
||||
|
||||
try {
|
||||
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
|
||||
await createEvent({ kind: 1068, content: finalContent, tags });
|
||||
resetComposeState();
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
toast({ title: 'Poll published!' });
|
||||
@@ -1006,7 +1023,7 @@ export function ComposeBox({
|
||||
};
|
||||
|
||||
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
|
||||
const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2;
|
||||
const isPollValid = content.trim().length > 0 && pollFilledCount >= 2;
|
||||
|
||||
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
|
||||
|
||||
@@ -1014,7 +1031,7 @@ export function ComposeBox({
|
||||
if (!user && compact) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-3 bg-background/85")}>
|
||||
<div className={cn("px-4 py-3 bg-background/85 rounded-2xl")}>
|
||||
{/* Preview toggle at top when not controlled and has previewable content */}
|
||||
{hasPreviewableContent && controlledPreviewMode === undefined && (
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
@@ -1062,31 +1079,83 @@ export function ComposeBox({
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{mode === 'poll' ? (
|
||||
/* ── Inline poll builder ─────────────────────────────── */
|
||||
<div className="pt-2.5 pb-1 space-y-3">
|
||||
{!previewMode ? (
|
||||
/* ── Edit mode — Textarea ────────────────────────────── */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={expand}
|
||||
onPaste={handlePaste}
|
||||
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
|
||||
className={cn(
|
||||
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
|
||||
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
|
||||
)}
|
||||
rows={1}
|
||||
disabled={!user}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MentionAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertMention={handleInsertMention}
|
||||
/>
|
||||
<EmojiShortcodeAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertEmoji={handleInsertShortcodeEmoji}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Preview mode - Show how post will look */
|
||||
mockEvent && (() => {
|
||||
const imetaMap = parseImetaMap(mockEvent.tags);
|
||||
const videos = extractVideoUrls(mockEvent.content);
|
||||
const imetaAudios = Array.from(imetaMap.values())
|
||||
.filter((e) => e.mime?.startsWith('audio/'))
|
||||
.map((e) => e.url);
|
||||
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
|
||||
const webxdcApps = Array.from(imetaMap.values()).filter(
|
||||
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
|
||||
);
|
||||
return (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={mockEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Poll options + settings — shown below the normal textarea/preview */}
|
||||
{mode === 'poll' && (
|
||||
<div className="space-y-3 pt-1">
|
||||
{/* Back to post link — hidden when poll mode is the only mode */}
|
||||
{initialMode !== 'poll' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('post')}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back to post
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Question */}
|
||||
<textarea
|
||||
value={pollQuestion}
|
||||
onChange={(e) => setPollQuestion(e.target.value)}
|
||||
placeholder="Ask a question…"
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-1.5">
|
||||
{pollOptions.map((opt, idx) => (
|
||||
@@ -1167,66 +1236,6 @@ export function ComposeBox({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : !previewMode ? (
|
||||
/* ── Edit mode — Textarea ────────────────────────────── */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={expand}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
|
||||
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
|
||||
)}
|
||||
rows={1}
|
||||
disabled={!user}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MentionAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertMention={handleInsertMention}
|
||||
/>
|
||||
<EmojiShortcodeAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertEmoji={handleInsertShortcodeEmoji}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Preview mode - Show how post will look */
|
||||
mockEvent && (() => {
|
||||
const imetaMap = parseImetaMap(mockEvent.tags);
|
||||
const videos = extractVideoUrls(mockEvent.content);
|
||||
const imetaAudios = Array.from(imetaMap.values())
|
||||
.filter((e) => e.mime?.startsWith('audio/'))
|
||||
.map((e) => e.url);
|
||||
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
|
||||
const webxdcApps = Array.from(imetaMap.values()).filter(
|
||||
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
|
||||
);
|
||||
return (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={mockEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Content warning input */}
|
||||
@@ -1258,7 +1267,7 @@ export function ComposeBox({
|
||||
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
|
||||
}} />
|
||||
) : (
|
||||
<EmbeddedNote eventId={quotedEvent.id} />
|
||||
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1479,11 +1488,11 @@ export function ComposeBox({
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
|
||||
import { isCustomEmoji, getCustomEmojiUrl, buildEmojiMap, type ResolvedEmoji } from '@/lib/customEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Threshold at or below which we apply nearest-neighbor scaling. */
|
||||
const PIXEL_ART_MAX = 16;
|
||||
|
||||
interface CustomEmojiImgProps {
|
||||
/** The shortcode name (without colons). */
|
||||
name: string;
|
||||
@@ -14,16 +17,30 @@ interface CustomEmojiImgProps {
|
||||
|
||||
/**
|
||||
* Renders a single custom emoji as an inline image.
|
||||
*
|
||||
* If the image's natural dimensions are 16x16 or smaller, nearest-neighbor
|
||||
* (`image-rendering: pixelated`) scaling is applied to preserve crisp pixels.
|
||||
*/
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
|
||||
const [pixelated, setPixelated] = useState(false);
|
||||
|
||||
const handleLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalWidth <= PIXEL_ART_MAX && img.naturalHeight <= PIXEL_ART_MAX) {
|
||||
setPixelated(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={`:${name}:`}
|
||||
title={`:${name}:`}
|
||||
className={className}
|
||||
style={pixelated ? { imageRendering: 'pixelated' } : undefined}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
|
||||
@@ -172,12 +173,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
className="group relative"
|
||||
title={`:${emoji.shortcode}:`}
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={`:${emoji.shortcode}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain rounded transition-transform group-hover:scale-125"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { SortableList, SortableItem } from '@/components/SortableList';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
@@ -506,11 +507,10 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
>
|
||||
<div className="flex items-center gap-2 pr-2 py-1.5">
|
||||
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
|
||||
@@ -375,11 +376,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{emoji.customUrl ? (
|
||||
<img
|
||||
src={emoji.customUrl}
|
||||
alt={`:${emoji.name}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.name}
|
||||
url={emoji.customUrl}
|
||||
className="size-5 object-contain shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
|
||||
@@ -1083,8 +1083,9 @@ function hasVideo(tags: string[][]): boolean {
|
||||
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
|
||||
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
|
||||
31990: 'App',
|
||||
32267: 'App',
|
||||
30063: 'Release',
|
||||
32267: 'Zapstore App',
|
||||
30063: 'Zapstore Release',
|
||||
3063: 'Zapstore Asset',
|
||||
15128: 'Nsite',
|
||||
35128: 'Nsite',
|
||||
31124: 'Blobbi',
|
||||
@@ -1110,7 +1111,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
|
||||
const KindIcon = useMemo(() => {
|
||||
if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText;
|
||||
// Fallback icons for well-known kinds not in EXTRA_KINDS
|
||||
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063) return Package;
|
||||
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package;
|
||||
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
|
||||
return FileText;
|
||||
}, [kindDef, addr.kind]);
|
||||
|
||||
@@ -47,6 +47,7 @@ const LANDING_KINDS = [
|
||||
30009, // Badge Definitions
|
||||
10008, // Profile Badges
|
||||
30008, // Profile Badges (legacy)
|
||||
31124, // Blobbi
|
||||
];
|
||||
|
||||
/** Webxdc needs a MIME-type tag filter, so it gets its own filter object. */
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import QRCode from 'qrcode';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
|
||||
|
||||
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
|
||||
const MIN_QR_CONTRAST = 3;
|
||||
|
||||
/** Saturation threshold (%) above which a color is considered "colorful". */
|
||||
const COLORFUL_SAT_MIN = 15;
|
||||
/** Lightness range within which a color appears visually colorful. */
|
||||
const COLORFUL_L_MIN = 20;
|
||||
const COLORFUL_L_MAX = 80;
|
||||
|
||||
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
|
||||
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
|
||||
if (!raw) return null;
|
||||
const { h, s, l } = parseHsl(raw);
|
||||
if ([h, s, l].some(isNaN)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function darkenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l > 0 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.max(0, l - 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function lightenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l < 100 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.min(100, l + 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best module color from primary and foreground.
|
||||
*
|
||||
* Strongly prefers primary since it carries the theme's brand identity.
|
||||
* Only picks foreground if it is colorful (saturation > threshold) AND
|
||||
* has significantly better contrast (> 1.5x) against the QR background.
|
||||
*/
|
||||
function pickModuleColor(
|
||||
primary: { h: number; s: number; l: number },
|
||||
foreground: { h: number; s: number; l: number } | null,
|
||||
bgRgb: [number, number, number],
|
||||
): { h: number; s: number; l: number } {
|
||||
const fgIsColorful = foreground
|
||||
&& foreground.s >= COLORFUL_SAT_MIN
|
||||
&& foreground.l >= COLORFUL_L_MIN
|
||||
&& foreground.l <= COLORFUL_L_MAX;
|
||||
|
||||
if (!fgIsColorful) return primary;
|
||||
|
||||
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
|
||||
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
|
||||
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
|
||||
const fgContrast = getContrastRatio(fgRgb, bgRgb);
|
||||
|
||||
// Foreground must be significantly better to override primary
|
||||
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive QR module and background hex colors from the active theme.
|
||||
*
|
||||
* Light themes: white background, best themed color as modules (darkened if needed).
|
||||
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
|
||||
*
|
||||
* "Best themed color" is --primary by default. If --foreground is colorful
|
||||
* (saturation > 15%) and offers better contrast, it wins instead.
|
||||
*/
|
||||
function getThemedQRColors(): { dark: string; light: string } {
|
||||
const primary = readCssHsl('--primary');
|
||||
const foreground = readCssHsl('--foreground');
|
||||
const background = readCssHsl('--background');
|
||||
|
||||
if (!primary) return { dark: '#000000', light: '#ffffff' };
|
||||
|
||||
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
|
||||
|
||||
if (!isDark) {
|
||||
const white: [number, number, number] = [255, 255, 255];
|
||||
const module = pickModuleColor(primary, foreground, white);
|
||||
return { dark: darkenToContrast(module, white), light: '#ffffff' };
|
||||
}
|
||||
|
||||
if (!background) return { dark: '#ffffff', light: '#000000' };
|
||||
const bgRgb = hslToRgb(background.h, background.s, background.l);
|
||||
const module = pickModuleColor(primary, foreground, bgRgb);
|
||||
return {
|
||||
dark: lightenToContrast(module, bgRgb),
|
||||
light: rgbToHex(...bgRgb),
|
||||
};
|
||||
}
|
||||
|
||||
interface FollowQRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey ?? '');
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = user ? metadata?.name || genUserName(user.pubkey) : '';
|
||||
|
||||
const npub = user ? nip19.npubEncode(user.pubkey) : '';
|
||||
const followUrl = npub ? `${window.location.origin}/follow/${npub}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!followUrl || !open) return;
|
||||
|
||||
const { dark, light } = getThemedQRColors();
|
||||
|
||||
QRCode.toDataURL(followUrl, {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
.then(setQrDataUrl)
|
||||
.catch(console.error);
|
||||
}, [followUrl, open]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(followUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-6 flex flex-col items-center gap-5 rounded-2xl">
|
||||
<DialogTitle className="sr-only">Share follow link</DialogTitle>
|
||||
|
||||
{/* Avatar + name */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-16 ring-2 ring-secondary">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="text-xl font-semibold">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Scan to follow <span className="text-foreground font-medium">{displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Follow QR code"
|
||||
className="w-full rounded-xl border border-border"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square rounded-xl border border-border bg-muted animate-pulse" />
|
||||
)}
|
||||
|
||||
{/* Copy link */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary flex-shrink-0" />
|
||||
: <Copy className="size-3.5 flex-shrink-0" />}
|
||||
<span className="truncate max-w-64">{followUrl}</span>
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -125,7 +126,7 @@ export function ImageGallery({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{/* Lightbox (portals to document.body internally to escape stacking contexts) */}
|
||||
{lightboxIndex !== null && lightboxIndex !== undefined && (
|
||||
<Lightbox
|
||||
images={images}
|
||||
@@ -484,7 +485,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
(i) => i >= 0 && i < images.length,
|
||||
);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-[100] animate-in fade-in duration-200"
|
||||
@@ -582,7 +583,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
{bottomBar}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useAuthors } from "@/hooks/useAuthors";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
|
||||
import { useEncryptedSettings, getLocalSettingsSync } from "@/hooks/useEncryptedSettings";
|
||||
import { type SyncPhase, useInitialSync } from "@/hooks/useInitialSync";
|
||||
import { useLoginActions } from "@/hooks/useLoginActions";
|
||||
import { useNostrPublish } from "@/hooks/useNostrPublish";
|
||||
@@ -65,8 +65,12 @@ interface InitialSyncGateProps {
|
||||
export function InitialSyncGate({ children }: InitialSyncGateProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { phase, markComplete } = useInitialSync();
|
||||
const { isLoading: settingsLoading } = useEncryptedSettings();
|
||||
const [preloadApp, setPreloadApp] = useState(false);
|
||||
const [signupActive, setSignupActive] = useState(false);
|
||||
// Track whether we've shown the app at least once so we don't re-gate on
|
||||
// subsequent background refetches (e.g. window focus).
|
||||
const hasShownApp = useRef(false);
|
||||
|
||||
const startSignup = useCallback(() => setSignupActive(true), []);
|
||||
|
||||
@@ -91,8 +95,10 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show sync/onboarding when logged out — just show the app
|
||||
// Don't show sync/onboarding when logged out — just show the app.
|
||||
// Reset hasShownApp so that re-login shows the spinner until settings load.
|
||||
if (!user) {
|
||||
hasShownApp.current = false;
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
@@ -121,6 +127,28 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// For returning users (phase === "complete"), decide whether to gate:
|
||||
// - If we have a local lastSync timestamp, localStorage is trustworthy and
|
||||
// we can render immediately. NostrSync will hot-swap any differences in
|
||||
// the background once the remote settings arrive.
|
||||
// - If there's NO local timestamp (e.g. localStorage was cleared, or settings
|
||||
// were never synced on this browser), show the spinner until settings load
|
||||
// so the user sees correct state from the start.
|
||||
// Only gate on the very first load — once the app has been shown, don't
|
||||
// re-gate on background refetches (e.g. window focus).
|
||||
if (phase === "complete" && settingsLoading && !hasShownApp.current) {
|
||||
const hasLocalSync = user ? getLocalSettingsSync(user.pubkey) > 0 : false;
|
||||
if (!hasLocalSync) {
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
<SyncScreen phase="syncing" />
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hasShownApp.current = true;
|
||||
|
||||
// idle or complete -> show app
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
UserPlus, LogOut,
|
||||
Loader2,
|
||||
Loader2, QrCode,
|
||||
} from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -15,6 +15,7 @@ import { SidebarNavList } from '@/components/SidebarNavItem';
|
||||
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
@@ -55,6 +56,7 @@ export function LeftSidebar() {
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
@@ -293,6 +295,10 @@ export function LeftSidebar() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-1">
|
||||
<button onClick={() => { setAccountPopoverOpen(false); setFollowQROpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
|
||||
<QrCode className="size-4 text-muted-foreground" />
|
||||
<span>Share profile</span>
|
||||
</button>
|
||||
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
|
||||
<UserPlus className="size-4 text-muted-foreground" />
|
||||
<span>Add another account</span>
|
||||
@@ -308,6 +314,7 @@ export function LeftSidebar() {
|
||||
)}
|
||||
|
||||
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,70 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Info, BookOpen, Shield, Code, ScrollText } from 'lucide-react';
|
||||
|
||||
interface LinkFooterProps {
|
||||
/** Optional callback fired when an internal (React Router) link is clicked. */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
const chipClass =
|
||||
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors';
|
||||
const iconClass = 'size-3 shrink-0';
|
||||
|
||||
/** Shared footer links used in both sidebars. */
|
||||
export function LinkFooter({ onNavigate }: LinkFooterProps) {
|
||||
return (
|
||||
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<footer className="mt-auto pt-3 pb-3 -mx-1 sidebar:bg-background/85 sidebar:rounded-xl sidebar:p-3">
|
||||
<nav className="flex items-center justify-center gap-0.5 flex-wrap" aria-label="Footer links">
|
||||
<a
|
||||
href="https://about.ditto.pub"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Info className={iconClass} />
|
||||
About
|
||||
</a>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://about.ditto.pub/docs/"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpen className={iconClass} />
|
||||
Docs
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
|
||||
<Link to="/privacy" className={chipClass} onClick={onNavigate}>
|
||||
<Shield className={iconClass} />
|
||||
Privacy
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://gitlab.com/soapbox-pub/ditto"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Code className={iconClass} />
|
||||
Source
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
|
||||
<Link to="/changelog" className={chipClass} onClick={onNavigate}>
|
||||
<ScrollText className={iconClass} />
|
||||
Changelog
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="text-xs leading-none" aria-hidden>🎭</span>
|
||||
Edit with Shakespeare
|
||||
</a>
|
||||
</p>
|
||||
</nav>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useState, useMemo, useCallback } from 'react';
|
||||
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { RightSidebar } from '@/components/RightSidebar';
|
||||
@@ -8,7 +8,7 @@ import { MobileBottomNav } from '@/components/MobileBottomNav';
|
||||
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { CursorFireEffect } from '@/components/CursorFireEffect';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -106,10 +106,12 @@ function MainLayoutInner() {
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
|
||||
const { config } = useAppContext();
|
||||
const { hidden: navHidden } = useScrollDirection(scrollContainer);
|
||||
|
||||
return (
|
||||
<CenterColumnContext.Provider value={centerColumnEl}>
|
||||
<DrawerContext.Provider value={openDrawer}>
|
||||
<NavHiddenContext.Provider value={navHidden}>
|
||||
{/* Magic Mouse fire particle overlay */}
|
||||
@@ -136,7 +138,10 @@ function MainLayoutInner() {
|
||||
being hidden. This depends on MobileTopBar having a transparent /
|
||||
semi-transparent background — a solid top bar would obscure the
|
||||
content underneath. Only active below the sidebar breakpoint. */}
|
||||
<div className={cn("relative flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}>
|
||||
<div
|
||||
ref={(el) => { centerColumnRef.current = el; setCenterColumnEl(el); }}
|
||||
className={cn("relative z-0 flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
{/* Desktop FAB — sticky within the feed column so it stays
|
||||
@@ -175,6 +180,7 @@ function MainLayoutInner() {
|
||||
)}
|
||||
</NavHiddenContext.Provider>
|
||||
</DrawerContext.Provider>
|
||||
</CenterColumnContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useId, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
@@ -11,6 +11,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
@@ -60,6 +61,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [accountExpanded, setAccountExpanded] = useState(false);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const { theme, customTheme, themes } = useTheme();
|
||||
|
||||
@@ -269,6 +271,13 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { handleClose(); setFollowQROpen(true); }}
|
||||
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<QrCode className="size-5 shrink-0" />
|
||||
<span>Share profile</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleClose(); setLoginDialogOpen(true); }}
|
||||
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
|
||||
@@ -318,7 +327,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<div className="px-2 safe-area-bottom">
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +371,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<div className="px-2 safe-area-bottom">
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,6 +385,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
onLogin={() => setLoginDialogOpen(false)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
.filter(r => r.read)
|
||||
.map(r => r.url);
|
||||
|
||||
// Include zapstore relay for kind 32267 (apps) and 30063 (releases)
|
||||
const ZAPSTORE_KINDS = [32267, 30063];
|
||||
// Include zapstore relay for kind 32267 (apps), 30063 (releases), and 3063 (assets)
|
||||
const ZAPSTORE_KINDS = [32267, 30063, 3063];
|
||||
if (filters.every((f) => f?.kinds?.every((k) => ZAPSTORE_KINDS.includes(k)))) {
|
||||
return new Map([ZAPSTORE_RELAY, ...readRelays].map(url => [url, filters]));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
|
||||
import { useEncryptedSettings, setLocalSettingsSync } from "@/hooks/useEncryptedSettings";
|
||||
import { isSyncDone } from "@/hooks/useInitialSync";
|
||||
import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
|
||||
@@ -246,16 +246,13 @@ export function NostrSync() {
|
||||
// Get the remote sync timestamp
|
||||
const remoteSync = encryptedSettings.lastSync || 0;
|
||||
|
||||
// On first load, seed the ref with the current remote timestamp so that
|
||||
// subsequent effect firings (e.g. after window focus) don't re-apply the
|
||||
// same snapshot. We still apply this snapshot below so that settings
|
||||
// restored from the query cache (seeded by useInitialSync) take effect
|
||||
// immediately on page reload without waiting for the 5-second delay.
|
||||
// On first load, mark seeded so this block only runs once.
|
||||
// We intentionally do NOT pre-set lastSyncedTimestamp here — leaving it
|
||||
// at 0 lets the `remoteSync <= lastSyncedTimestamp` guard below fall
|
||||
// through so the settings are actually applied on this first pass.
|
||||
// Line 277 then records the timestamp to prevent re-application.
|
||||
if (!seededTimestamp) {
|
||||
lastSyncedTimestamp.current = remoteSync;
|
||||
setSeededTimestamp(true);
|
||||
// Fall through — apply the settings this time so that sidebarOrder
|
||||
// and other fields are always applied on the first load.
|
||||
}
|
||||
|
||||
// Don't overwrite local config if we just saved settings (short-circuit for
|
||||
@@ -434,6 +431,12 @@ export function NostrSync() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the sync timestamp so the next page load can render immediately
|
||||
// from localStorage without showing the spinner.
|
||||
if (user && remoteSync > 0) {
|
||||
setLocalSettingsSync(user.pubkey, remoteSync);
|
||||
}
|
||||
}, [
|
||||
user,
|
||||
encryptedSettings,
|
||||
|
||||
+241
-382
@@ -21,7 +21,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */
|
||||
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
|
||||
@@ -74,6 +74,7 @@ import { EncryptedMessageContent } from "@/components/EncryptedMessageContent";
|
||||
import { EncryptedLetterContent } from "@/components/EncryptedLetterContent";
|
||||
import { VanishCardCompact } from "@/components/VanishEventContent";
|
||||
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
|
||||
import { ZapstoreReleaseContent, ZapstoreAssetContent } from "@/components/ZapstoreReleaseContent";
|
||||
import { AppHandlerContent } from "@/components/AppHandlerContent";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
@@ -97,7 +98,8 @@ import { genUserName } from "@/lib/genUserName";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
|
||||
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
|
||||
import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents";
|
||||
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
|
||||
import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
|
||||
import { isSingleImagePost } from "@/lib/noteContent";
|
||||
import { shareOrCopy } from "@/lib/share";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
@@ -117,6 +119,113 @@ function ProfileCardContent({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Shared activity card shell for reaction / repost / zap / poll vote ──── */
|
||||
|
||||
interface ActivityCardProps {
|
||||
/** The round element in the left column (icon bubble or avatar). */
|
||||
icon: ReactNode;
|
||||
/** The actor row content (avatar + name + label + timestamp). */
|
||||
actorRow: ReactNode;
|
||||
/** Optional extra content below the actor row (zap message, vote label, etc.). */
|
||||
children?: ReactNode;
|
||||
/** Threaded mode: connector line below icon, no bottom border. */
|
||||
threaded?: boolean;
|
||||
/** Last item in thread — no connector line, has bottom border. */
|
||||
threadedLast?: boolean;
|
||||
/** Custom connector line class. */
|
||||
threadedLineClassName?: string;
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler;
|
||||
onAuxClick?: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
export function ActivityCard({
|
||||
icon,
|
||||
actorRow,
|
||||
children,
|
||||
threaded,
|
||||
threadedLast,
|
||||
threadedLineClassName,
|
||||
className,
|
||||
onClick,
|
||||
onAuxClick,
|
||||
}: ActivityCardProps) {
|
||||
const isThreaded = threaded || threadedLast;
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
isThreaded
|
||||
? cn("pt-3", threaded ? "pb-0" : "pb-3 border-b border-border")
|
||||
: "py-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
onAuxClick={onAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{icon}
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex-1 min-w-0", isThreaded ? "min-h-10 flex flex-col justify-center" : "", threaded && "pb-3")}>
|
||||
{actorRow}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable actor row: small avatar + display name + action label + timestamp. */
|
||||
export interface ActorRowProps {
|
||||
pubkey: string;
|
||||
profileUrl: string;
|
||||
avatarShape: Parameters<typeof Avatar>[0]['shape'];
|
||||
picture?: string;
|
||||
displayName: string;
|
||||
authorEvent?: NostrEvent;
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
/** Extra inline elements after the label (e.g. zap amount). */
|
||||
extra?: ReactNode;
|
||||
/** Formatted timestamp string (e.g. timeAgo or full date). */
|
||||
timestampLabel: string;
|
||||
}
|
||||
|
||||
export function ActorRow({ pubkey, profileUrl, avatarShape, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
|
||||
{authorEvent ? <EmojifiedText tags={authorEvent.tags}>{displayName}</EmojifiedText> : displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
|
||||
{extra}
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timestampLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoteCardProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
@@ -218,6 +327,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
|
||||
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
|
||||
|
||||
const pollVoteLabel = usePollVoteLabel(event);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
@@ -248,7 +359,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
target.closest("[data-radix-dialog-content]") ||
|
||||
target.closest("[data-vaul-drawer]") ||
|
||||
target.closest("[data-vaul-drawer-overlay]") ||
|
||||
target.closest('[data-testid="zap-modal"]')
|
||||
target.closest('[data-testid="zap-modal"]') ||
|
||||
target.closest("button") ||
|
||||
target.closest("a")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -263,7 +376,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
target.closest("[data-radix-dialog-content]") ||
|
||||
target.closest("[data-vaul-drawer]") ||
|
||||
target.closest("[data-vaul-drawer-overlay]") ||
|
||||
target.closest('[data-testid="zap-modal"]')
|
||||
target.closest('[data-testid="zap-modal"]') ||
|
||||
target.closest("button") ||
|
||||
target.closest("a")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -290,6 +405,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isProfileBadges = event.kind === 10008 || event.kind === 30008;
|
||||
const isBadge = isBadgeDefinition || isProfileBadges;
|
||||
const isReaction = event.kind === 7;
|
||||
const isPollVote = event.kind === 1018;
|
||||
const isRepost = event.kind === 6 || event.kind === 16;
|
||||
const isPhoto = event.kind === 20;
|
||||
const isNormalVideo = event.kind === 21;
|
||||
@@ -307,6 +423,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isCustomNip = event.kind === 30817;
|
||||
const isNsite = event.kind === 15128 || event.kind === 35128;
|
||||
const isZapstoreApp = event.kind === 32267;
|
||||
const isZapstoreRelease = event.kind === 30063;
|
||||
const isZapstoreAsset = event.kind === 3063;
|
||||
const isAppHandler = event.kind === 31990;
|
||||
const isEncryptedDM = event.kind === 4;
|
||||
const isLetter = event.kind === 8211;
|
||||
@@ -332,12 +450,15 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isEmojiPack &&
|
||||
!isBadge &&
|
||||
!isReaction &&
|
||||
!isPollVote &&
|
||||
!isRepost &&
|
||||
!isPhoto &&
|
||||
!isVideo &&
|
||||
!isAudioKind &&
|
||||
!isDevKind &&
|
||||
!isZapstoreApp &&
|
||||
!isZapstoreRelease &&
|
||||
!isZapstoreAsset &&
|
||||
!isAppHandler &&
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
@@ -415,11 +536,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
return [parentAuthor];
|
||||
}, [event.tags, isTextNote, isReply, event.pubkey]);
|
||||
|
||||
// Extract the parent event ID for reply hover card preview
|
||||
const parentEventId = useMemo(() => {
|
||||
// Extract the parent event ID + relay/author hints for reply hover card preview
|
||||
const parentHints = useMemo(() => {
|
||||
if (!isReply) return undefined;
|
||||
return getParentEventId(event);
|
||||
return getParentEventHints(event);
|
||||
}, [event, isReply]);
|
||||
const parentEventId = parentHints?.id;
|
||||
|
||||
// Kind 34236 specific
|
||||
const imeta = useMemo(
|
||||
@@ -461,7 +583,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
|
||||
{isComment && <CommentContext event={event} />}
|
||||
{isReply && (
|
||||
<ReplyContext pubkeys={replyToPubkeys} parentEventId={parentEventId} />
|
||||
<ReplyContext
|
||||
pubkeys={replyToPubkeys}
|
||||
parentEventId={parentEventId}
|
||||
parentRelayHint={parentHints?.relayHint}
|
||||
parentAuthorHint={parentHints?.authorHint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content — kind-based dispatch, guarded by NIP-36 content-warning */}
|
||||
@@ -534,7 +661,23 @@ export const NoteCard = memo(function NoteCard({
|
||||
) : isNsite ? (
|
||||
<NsiteCard event={event} />
|
||||
) : isZapstoreApp ? (
|
||||
<ZapstoreAppContent event={event} compact />
|
||||
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
|
||||
<div className="px-3.5 pb-3.5 pt-3">
|
||||
<ZapstoreAppContent event={event} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : isZapstoreRelease ? (
|
||||
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
|
||||
<div className="px-3.5 pb-3.5 pt-3">
|
||||
<ZapstoreReleaseContent event={event} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : isZapstoreAsset ? (
|
||||
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
|
||||
<div className="px-3.5 pb-3.5 pt-3">
|
||||
<ZapstoreAssetContent event={event} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : isAppHandler ? (
|
||||
<AppHandlerContent event={event} compact />
|
||||
) : isEncryptedDM ? (
|
||||
@@ -777,399 +920,107 @@ export const NoteCard = memo(function NoteCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reaction layout (kind 7) — compact activity-style card ──
|
||||
// ── Reaction layout (kind 7) ──
|
||||
if (isReaction) {
|
||||
// Threaded reaction (used in AncestorThread with connector line)
|
||||
if (threaded || threadedLast) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
threaded ? "pb-0" : "pb-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Reaction emoji bubble instead of avatar */}
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-lg leading-none">
|
||||
<ReactionEmoji
|
||||
content={event.content}
|
||||
tags={event.tags}
|
||||
className="h-5 w-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex items-center min-h-10",
|
||||
threaded && "pb-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage
|
||||
src={metadata?.picture}
|
||||
alt={displayName}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">reacted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal reaction card (standalone or in feed)
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Large reaction emoji */}
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
|
||||
<ReactionEmoji
|
||||
content={event.content}
|
||||
tags={event.tags}
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
<ActivityCard
|
||||
icon={
|
||||
<div className={cn("flex items-center justify-center rounded-full bg-pink-500/10 shrink-0 text-lg leading-none", iconSize)}>
|
||||
<ReactionEmoji content={event.content} tags={event.tags} className="h-5 w-5 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Author + "reacted" label */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground">reacted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
|
||||
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Repost layout (kind 6 / 16) — compact activity-style card ──
|
||||
// ── Repost layout (kind 6 / 16) ──
|
||||
if (isRepost) {
|
||||
// Threaded repost (used in AncestorThread with connector line)
|
||||
if (threaded || threadedLast) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
threaded ? "pb-0" : "pb-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Repost icon bubble instead of avatar */}
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-accent/10 shrink-0">
|
||||
<RepostIcon className="size-5 text-accent" />
|
||||
</div>
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex items-center min-h-10",
|
||||
threaded && "pb-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage
|
||||
src={metadata?.picture}
|
||||
alt={displayName}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">reposted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal repost card (standalone or in feed)
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Repost icon */}
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-accent/10 shrink-0">
|
||||
<ActivityCard
|
||||
icon={
|
||||
<div className={cn("flex items-center justify-center rounded-full bg-accent/10 shrink-0", iconSize)}>
|
||||
<RepostIcon className="size-5 text-accent" />
|
||||
</div>
|
||||
|
||||
{/* Author + "reposted" label */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground">reposted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
|
||||
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zap receipt layout (kind 9735) — mirrors reaction layout exactly ──
|
||||
// ── Zap receipt layout (kind 9735) ──
|
||||
if (isZap) {
|
||||
const zapAmountSats = Math.floor(extractZapAmount(event) / 1000);
|
||||
const zapMessage = extractZapMessage(event);
|
||||
|
||||
const zapActorRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
{zapSender.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{zapSenderPubkey && (
|
||||
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
|
||||
<Link to={zapSenderUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={zapSenderShape} className="size-6">
|
||||
<AvatarImage src={zapSenderMeta?.picture} alt={zapSenderName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{zapSenderName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
{zapSenderPubkey && (
|
||||
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
|
||||
<Link to={zapSenderUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
|
||||
{zapSender.data?.event ? <EmojifiedText tags={zapSender.data.event.tags}>{zapSenderName}</EmojifiedText> : zapSenderName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground shrink-0">zapped</span>
|
||||
{zapAmountSats > 0 && (
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<ActivityCard
|
||||
icon={
|
||||
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
|
||||
<Zap className="size-5 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} avatarShape={zapSenderShape} picture={zapSenderMeta?.picture}
|
||||
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
|
||||
extra={zapAmountSats > 0 ? (
|
||||
<span className="text-sm font-semibold text-amber-500 shrink-0">
|
||||
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (threaded || threadedLast) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
threaded ? "pb-0" : "pb-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-amber-500/10 shrink-0">
|
||||
<Zap className="size-5 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
{threaded && <div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />}
|
||||
</div>
|
||||
<div className={cn("flex-1 min-w-0 flex flex-col justify-center min-h-10", threaded && "pb-3")}>
|
||||
{zapActorRow}
|
||||
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">“{zapMessage}”</p>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
) : undefined}
|
||||
/>
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-amber-500/10 shrink-0">
|
||||
<Zap className="size-5 text-amber-500 fill-amber-500" />
|
||||
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">“{zapMessage}”</p>}
|
||||
</ActivityCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Poll vote layout (kind 1018) ──
|
||||
if (isPollVote) {
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<ActivityCard
|
||||
icon={
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className={iconSize}>
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
}
|
||||
actorRow={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
|
||||
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground shrink-0">voted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{zapActorRow}
|
||||
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">“{zapMessage}”</p>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
>
|
||||
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
|
||||
</ActivityCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2005,7 +1856,15 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
32267: {
|
||||
icon: Package,
|
||||
action: "published an app",
|
||||
action: "published a Zapstore app",
|
||||
},
|
||||
30063: {
|
||||
icon: Package,
|
||||
action: "published a Zapstore release",
|
||||
},
|
||||
3063: {
|
||||
icon: Package,
|
||||
action: "published a Zapstore asset",
|
||||
},
|
||||
31990: {
|
||||
icon: Package,
|
||||
|
||||
+103
-83
@@ -1,37 +1,22 @@
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import { ExternalLink, FileText, Globe, Server } from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { ExternalLink, FileText, Globe, Play, Server } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalFavicon } from "@/components/ExternalFavicon";
|
||||
import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useLinkPreview } from "@/hooks/useLinkPreview";
|
||||
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NsiteCardProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
|
||||
function hexToBase36(hex: string): string {
|
||||
let n = 0n;
|
||||
for (let i = 0; i < hex.length; i++) {
|
||||
n = n * 16n + BigInt(parseInt(hex[i], 16));
|
||||
}
|
||||
const b36 = n.toString(36);
|
||||
return b36.padStart(50, "0");
|
||||
}
|
||||
|
||||
/** Build the nsite.lol gateway URL for an nsite event. */
|
||||
function getNsiteUrl(event: NostrEvent): string {
|
||||
const dTag = event.tags.find(([n]) => n === "d")?.[1];
|
||||
|
||||
if (event.kind === 35128 && dTag) {
|
||||
const pubkeyB36 = hexToBase36(event.pubkey);
|
||||
return `https://${pubkeyB36}${dTag}.nsite.lol`;
|
||||
}
|
||||
|
||||
const npub = nip19.npubEncode(event.pubkey);
|
||||
return `https://${npub}.nsite.lol`;
|
||||
return `https://${getNsiteSubdomain(event)}.nsite.lol`;
|
||||
}
|
||||
|
||||
/** Renders an nsite deployment card with a rich link preview. */
|
||||
@@ -51,90 +36,125 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
const image = preview?.thumbnail_url;
|
||||
const previewTitle = preview?.title;
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return <NsiteCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"group block mt-2 rounded-2xl border border-border overflow-hidden",
|
||||
"group mt-2 rounded-2xl border border-border overflow-hidden",
|
||||
"hover:bg-secondary/40 transition-colors",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Link preview thumbnail */}
|
||||
{image && (
|
||||
<div className="w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3.5 py-2.5 space-y-1.5">
|
||||
{/* Title with favicon */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{previewTitle || displayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
|
||||
{(description || preview?.author_name) && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{description || preview?.author_name}
|
||||
</p>
|
||||
{/* Link preview thumbnail — clicking navigates to the site */}
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{image && (
|
||||
<div className="w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full h-[180px] object-cover transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deployment stats + source link */}
|
||||
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
|
||||
{pathTags.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FileText className="size-3" />
|
||||
{pathTags.length} {pathTags.length === 1 ? "file" : "files"}
|
||||
</span>
|
||||
<div className="px-3.5 pt-2.5 pb-1.5 space-y-1.5">
|
||||
{/* Title with favicon */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ExternalFavicon url={siteUrl} size={16} className="shrink-0" />
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{previewTitle || displayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description — prefer event description (it's curated), fall back to OEmbed author */}
|
||||
{(description || preview?.author_name) && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{description || preview?.author_name}
|
||||
</p>
|
||||
)}
|
||||
{serverTags.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Server className="size-3" />
|
||||
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
|
||||
{/* Deployment stats */}
|
||||
{(pathTags.length > 0 || serverTags.length > 0) && (
|
||||
<div className="flex items-center gap-3 pt-0.5 text-[11px] text-muted-foreground">
|
||||
{pathTags.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FileText className="size-3" />
|
||||
{pathTags.length} {pathTags.length === 1 ? "file" : "files"}
|
||||
</span>
|
||||
)}
|
||||
{serverTags.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Server className="size-3" />
|
||||
{serverTags.length} {serverTags.length === 1 ? "server" : "servers"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sourceUrl && (
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Action row */}
|
||||
<div className="px-3.5 pb-2.5 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
Run
|
||||
</Button>
|
||||
{sourceUrl ? (
|
||||
<Button asChild size="sm" variant="secondary" className="h-7 text-xs">
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
|
||||
"hover:bg-primary/10 hover:text-primary transition-colors",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Globe className="size-3" />
|
||||
<span>Source</span>
|
||||
<Globe className="size-3 mr-1" />
|
||||
Source
|
||||
</a>
|
||||
)}
|
||||
{!sourceUrl && (
|
||||
<span className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded-full hover:bg-primary/10 hover:text-primary transition-colors">
|
||||
<ExternalLink className="size-3" />
|
||||
<span>Visit</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="sm" variant="secondary" className="h-7 text-xs">
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-3 mr-1" />
|
||||
Visit
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<NsitePreviewDialog
|
||||
event={event}
|
||||
appName={previewTitle || displayName || "nsite"}
|
||||
appPicture={undefined}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Package, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCenterColumn } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
|
||||
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
|
||||
interface Rect { left: number; top: number; width: number; height: number }
|
||||
|
||||
/** Track the viewport-relative bounding rect of an element, updating on resize. */
|
||||
function useElementRect(el: HTMLElement | null): Rect | null {
|
||||
const [rect, setRect] = useState<Rect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!el) { setRect(null); return; }
|
||||
|
||||
const measure = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setRect({ left: r.left, top: r.top, width: r.width, height: r.height });
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
window.addEventListener('resize', measure);
|
||||
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
|
||||
}, [el]);
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** The wildcard-to-localhost preview domain used by Shakespeare's iframe-fetch-client. */
|
||||
const PREVIEW_DOMAIN = 'local-shakespeare.dev';
|
||||
|
||||
interface JSONRPCFetchRequest {
|
||||
jsonrpc: '2.0';
|
||||
method: 'fetch';
|
||||
params: {
|
||||
request: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface JSONRPCResponse {
|
||||
jsonrpc: '2.0';
|
||||
result?: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the path→sha256 manifest from a nsite event's `path` tags.
|
||||
* Each path tag has the format: ["path", "/file/path", "<sha256>"]
|
||||
*/
|
||||
function buildManifest(event: NostrEvent): Map<string, string> {
|
||||
const manifest = new Map<string, string>();
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'path' && tag[1] && tag[2]) {
|
||||
manifest.set(tag[1], tag[2]);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Blossom servers for a nsite event.
|
||||
* Prefers the `server` tags on the event; falls back to the provided app servers.
|
||||
*/
|
||||
function resolveServers(event: NostrEvent, appServers: string[]): string[] {
|
||||
const eventServers = event.tags
|
||||
.filter(([name]) => name === 'server')
|
||||
.map(([, url]) => url)
|
||||
.filter((url) => {
|
||||
try { new URL(url); return true; } catch { return false; }
|
||||
});
|
||||
|
||||
return eventServers.length > 0 ? eventServers : appServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a blob from the given sha256 by trying each Blossom server in order.
|
||||
* Returns a Response from the first server that responds successfully, or
|
||||
* throws if all servers fail.
|
||||
*/
|
||||
async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
for (const server of servers) {
|
||||
const base = server.replace(/\/+$/, '');
|
||||
const url = `${base}/${sha256}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return res;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess a MIME type from a file path extension.
|
||||
* Falls back to 'application/octet-stream' for unknown extensions.
|
||||
*/
|
||||
function guessMimeType(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
||||
const map: Record<string, string> = {
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
css: 'text/css',
|
||||
js: 'application/javascript',
|
||||
mjs: 'application/javascript',
|
||||
json: 'application/json',
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
otf: 'font/otf',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
wav: 'audio/wav',
|
||||
wasm: 'application/wasm',
|
||||
xml: 'application/xml',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
};
|
||||
return map[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
interface NsitePreviewDialogProps {
|
||||
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
|
||||
event: NostrEvent;
|
||||
/** Display name for the app. */
|
||||
appName: string;
|
||||
/** Optional app icon URL. */
|
||||
appPicture?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-app preview panel that covers the center column and loads an nsite in
|
||||
* a sandboxed iframe, using the Shakespeare iframe-fetch-client protocol over
|
||||
* local-shakespeare.dev.
|
||||
*
|
||||
* Instead of proxying requests through an nsite gateway, this component serves
|
||||
* files directly from Blossom servers using the manifest data embedded in the
|
||||
* nsite event's `path` tags. Each path tag maps a file path to its sha256 hash,
|
||||
* which is used to construct a Blossom content-addressed URL.
|
||||
*
|
||||
* The panel is portaled into the center column DOM element (via CenterColumnContext)
|
||||
* and uses `position: fixed` to fill the viewport column area.
|
||||
*
|
||||
* The parent window intercepts JSON-RPC `fetch` requests from the iframe and
|
||||
* serves them directly from Blossom, so the SPA can run without any gateway dependency.
|
||||
*/
|
||||
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const centerColumn = useCenterColumn();
|
||||
const columnRect = useElementRect(open ? centerColumn : null);
|
||||
const { config } = useAppContext();
|
||||
|
||||
// Derive the iframe origin from the NIP-5A canonical subdomain for this event
|
||||
const subdomain = getNsiteSubdomain(event);
|
||||
const iframeOrigin = `https://${subdomain}.${PREVIEW_DOMAIN}`;
|
||||
const iframeSrc = `${iframeOrigin}/`;
|
||||
|
||||
// Build the manifest and server list from the event (memoised per event identity)
|
||||
const manifest = useRef<Map<string, string>>(new Map());
|
||||
const servers = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
manifest.current = buildManifest(event);
|
||||
const appServers = getEffectiveBlossomServers(
|
||||
config.blossomServerMetadata,
|
||||
config.useAppBlossomServers ?? true,
|
||||
);
|
||||
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
|
||||
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
|
||||
|
||||
/** Send a JSON-RPC response back to the iframe. */
|
||||
const sendResponse = useCallback((message: JSONRPCResponse) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
|
||||
}, [iframeOrigin]);
|
||||
|
||||
/** Handle a fetch request from the iframe by serving files directly from Blossom. */
|
||||
const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => {
|
||||
const { params, id } = request;
|
||||
const { request: fetchRequest } = params;
|
||||
|
||||
try {
|
||||
const requestedUrl = new URL(fetchRequest.url);
|
||||
|
||||
// Only serve requests for our iframe origin
|
||||
if (requestedUrl.origin !== iframeOrigin) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32003, message: 'Origin mismatch' },
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip query string from path for manifest lookup
|
||||
const requestedPath = requestedUrl.pathname;
|
||||
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
// If not found, fall back to /index.html (SPA client-side routing).
|
||||
let sha256 = manifest.current.get(requestedPath);
|
||||
let servingPath = requestedPath;
|
||||
|
||||
if (!sha256) {
|
||||
sha256 = manifest.current.get('/index.html');
|
||||
servingPath = '/index.html';
|
||||
}
|
||||
|
||||
if (!sha256) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: btoa('Not Found'),
|
||||
},
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the blob from Blossom, trying each server in order
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
|
||||
// Read as ArrayBuffer → base64 so binary assets work correctly
|
||||
const buffer = await res.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const bodyBase64 = btoa(binary);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
// the manifest is authoritative for the correct MIME type.
|
||||
const contentType = guessMimeType(servingPath);
|
||||
|
||||
// The iframe-fetch-client (main.js) checks headers with Title-Case keys
|
||||
// (e.g. "Content-Type"), and does an exact equality check against "text/html"
|
||||
// for routing decisions.
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(bytes.byteLength),
|
||||
};
|
||||
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: responseHeaders,
|
||||
body: bodyBase64,
|
||||
},
|
||||
id,
|
||||
});
|
||||
} catch (err) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32002, message: String(err) },
|
||||
id,
|
||||
});
|
||||
}
|
||||
}, [iframeOrigin, sendResponse]);
|
||||
|
||||
/** Handle navigation state updates from the iframe (no-op). */
|
||||
const handleNavigationState = useCallback((_params: {
|
||||
currentUrl: string;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
}) => {
|
||||
// intentionally empty
|
||||
}, []);
|
||||
|
||||
// Listen for messages from the iframe
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== iframeOrigin) return;
|
||||
const message = event.data;
|
||||
if (message?.jsonrpc !== '2.0') return;
|
||||
if (message.method === 'fetch') {
|
||||
handleFetch(message as JSONRPCFetchRequest);
|
||||
} else if (message.method === 'updateNavigationState') {
|
||||
handleNavigationState(message.params);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [iframeOrigin, handleFetch, handleNavigationState]);
|
||||
|
||||
|
||||
|
||||
if (!open || !centerColumn || !columnRect) return null;
|
||||
|
||||
// If the user has scrolled down, columnRect.top is negative (the column top
|
||||
// is above the viewport). Clamp to 0 so the panel always starts at the
|
||||
// viewport top edge and never grows taller than the viewport.
|
||||
const panelTop = Math.max(0, columnRect.top);
|
||||
const panelHeight = window.innerHeight - panelTop;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed z-50 flex flex-col bg-background"
|
||||
style={{
|
||||
left: columnRect.left,
|
||||
top: panelTop,
|
||||
width: columnRect.width,
|
||||
height: panelHeight,
|
||||
}}
|
||||
>
|
||||
{/* Nav bar */}
|
||||
<div className="h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{appPicture ? (
|
||||
<img
|
||||
src={appPicture}
|
||||
alt={appName}
|
||||
className="size-6 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Package className="size-3.5 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{appName}</span>
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background">
|
||||
<iframe
|
||||
key={`${subdomain}-${open}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
className="w-full h-full border-0"
|
||||
title={`${appName} preview`}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
+362
-22
@@ -1,10 +1,22 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { BarChart3, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BarChart3, CheckCircle2, Clock, X, ChevronRight } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -61,6 +73,61 @@ function tallyVotes(
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Get voter events for a specific option ID. */
|
||||
function getVotersForOption(
|
||||
votes: NostrEvent[],
|
||||
optionId: string,
|
||||
pollType: string,
|
||||
): NostrEvent[] {
|
||||
return votes.filter((vote) => {
|
||||
const responseTags = vote.tags.filter(([n]) => n === 'response');
|
||||
if (pollType === 'singlechoice') {
|
||||
return responseTags[0]?.[1] === optionId;
|
||||
} else {
|
||||
return responseTags.some(([, id]) => id === optionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Clickable avatar stack + "N votes" label. */
|
||||
function VoterAvatarsButton({
|
||||
votes,
|
||||
totalVotes,
|
||||
authorsMap,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
votes: NostrEvent[];
|
||||
totalVotes: number;
|
||||
authorsMap?: Map<string, { pubkey: string; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} className={cn('flex items-center gap-1.5 group', className)}>
|
||||
<div className="flex -space-x-1.5">
|
||||
{votes.slice(0, 6).map((vote) => {
|
||||
const authorData = authorsMap?.get(vote.pubkey);
|
||||
const metadata = authorData?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.name || genUserName(vote.pubkey);
|
||||
return (
|
||||
<Avatar key={vote.pubkey} shape={avatarShape} className="size-5 ring-1 ring-background">
|
||||
<AvatarImage src={metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PollContent({ event }: { event: NostrEvent }) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
@@ -72,6 +139,10 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
const endsAt = getTag(event.tags, 'endsAt');
|
||||
const isExpired = endsAt ? Number(endsAt) < Math.floor(Date.now() / 1000) : false;
|
||||
|
||||
// Modal state
|
||||
const [votersModalOpen, setVotersModalOpen] = useState(false);
|
||||
const [votersModalOptionId, setVotersModalOptionId] = useState<string | null>(null);
|
||||
|
||||
// Fetch vote events
|
||||
const { data: votes } = useQuery<NostrEvent[]>({
|
||||
queryKey: ['poll-votes', event.id],
|
||||
@@ -126,6 +197,19 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
});
|
||||
};
|
||||
|
||||
// Collect all voter pubkeys for batch profile fetching
|
||||
const allVoterPubkeys = useMemo(() => {
|
||||
if (!votes) return [];
|
||||
return votes.map((v) => v.pubkey);
|
||||
}, [votes]);
|
||||
|
||||
const { data: authorsMap } = useAuthors(allVoterPubkeys);
|
||||
|
||||
const openVotersModal = (optionId: string | null) => {
|
||||
setVotersModalOptionId(optionId);
|
||||
setVotersModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Question */}
|
||||
@@ -133,7 +217,7 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
<NoteContent event={event} />
|
||||
</div>
|
||||
|
||||
{/* Poll type + expiry badges */}
|
||||
{/* Poll type + expiry badges + voter avatars + vote count */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground bg-secondary/60 px-2 py-0.5 rounded-full">
|
||||
<BarChart3 className="size-3" />
|
||||
@@ -145,6 +229,17 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
Ended
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Voter avatars + count pushed to the right */}
|
||||
{showResults && totalVotes > 0 && (
|
||||
<VoterAvatarsButton
|
||||
votes={votes ?? []}
|
||||
totalVotes={totalVotes}
|
||||
authorsMap={authorsMap}
|
||||
onClick={() => openVotersModal(null)}
|
||||
className="ml-auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -192,26 +287,271 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Vote button or total */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
{!showResults && user && (
|
||||
<button
|
||||
onClick={handleVote}
|
||||
disabled={!selectedOption}
|
||||
className={cn(
|
||||
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
|
||||
selectedOption
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-secondary text-muted-foreground cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
Vote
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Vote button + voter avatars (voting mode only) */}
|
||||
{!showResults && (
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
{totalVotes > 0 ? (
|
||||
<VoterAvatarsButton
|
||||
votes={votes ?? []}
|
||||
totalVotes={totalVotes}
|
||||
authorsMap={authorsMap}
|
||||
onClick={() => openVotersModal(null)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">0 votes</span>
|
||||
)}
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleVote}
|
||||
disabled={!selectedOption}
|
||||
className={cn(
|
||||
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
|
||||
selectedOption
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-secondary text-muted-foreground cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
Vote
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voters Modal */}
|
||||
<PollVotersModal
|
||||
open={votersModalOpen}
|
||||
onOpenChange={setVotersModalOpen}
|
||||
allVotes={votes ?? []}
|
||||
options={options}
|
||||
pollType={pollType}
|
||||
initialOptionId={votersModalOptionId}
|
||||
authorsMap={authorsMap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Poll Voters Modal ──── */
|
||||
|
||||
interface PollVotersModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
allVotes: NostrEvent[];
|
||||
options: PollOption[];
|
||||
pollType: string;
|
||||
initialOptionId?: string | null;
|
||||
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
||||
}
|
||||
|
||||
function PollVotersModal({ open, onOpenChange, allVotes, options, pollType, initialOptionId, authorsMap }: PollVotersModalProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<string | null>(initialOptionId ?? null);
|
||||
|
||||
// Sync filter when modal opens with a specific option
|
||||
useMemo(() => {
|
||||
if (open) setActiveFilter(initialOptionId ?? null);
|
||||
}, [open, initialOptionId]);
|
||||
|
||||
// Build a map from option ID to label for display
|
||||
const optionLabelMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const opt of options) {
|
||||
map.set(opt.id, opt.label);
|
||||
}
|
||||
return map;
|
||||
}, [options]);
|
||||
|
||||
// Filter voters based on active filter
|
||||
const filteredVoters = useMemo(() => {
|
||||
if (activeFilter === null) return allVotes;
|
||||
return getVotersForOption(allVotes, activeFilter, pollType);
|
||||
}, [allVotes, activeFilter, pollType]);
|
||||
|
||||
// Tally per option for the count badges
|
||||
const tally = useMemo(() => tallyVotes(allVotes, pollType), [allVotes, pollType]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[460px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12">
|
||||
<DialogTitle className="text-base font-semibold">Voters</DialogTitle>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Option filter bars — scrollable when more than 3 */}
|
||||
<ScrollArea className={cn('px-4', options.length > 2 && 'max-h-[120px]')}>
|
||||
<div className="space-y-1.5">
|
||||
{/* "All" bar */}
|
||||
<button
|
||||
onClick={() => setActiveFilter(null)}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
|
||||
activeFilter === null ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-all duration-500',
|
||||
activeFilter === null ? 'bg-primary/15' : 'bg-secondary/40',
|
||||
)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div className="relative flex items-center justify-between px-3 py-2">
|
||||
<span className={cn('text-sm', activeFilter === null && 'font-semibold')}>All</span>
|
||||
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
|
||||
{allVotes.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Per-option bars */}
|
||||
{options.map((opt) => {
|
||||
const count = tally.get(opt.id) ?? 0;
|
||||
const pct = allVotes.length > 0 ? Math.round((count / allVotes.length) * 100) : 0;
|
||||
const isActive = activeFilter === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setActiveFilter(opt.id)}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
|
||||
isActive ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-all duration-500',
|
||||
isActive ? 'bg-primary/15' : 'bg-secondary/40',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="relative flex items-center justify-between px-3 py-2">
|
||||
<span className={cn('text-sm break-words min-w-0', isActive && 'font-semibold')}>{opt.label}</span>
|
||||
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Primary accent divider — only when scrollbox is active */}
|
||||
{options.length > 2 && <div className="mx-4 h-1 bg-primary rounded-full" />}
|
||||
|
||||
{/* Voter list */}
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{filteredVoters.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No votes yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{filteredVoters.map((vote) => (
|
||||
<VoterRow
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
optionLabelMap={optionLabelMap}
|
||||
pollType={pollType}
|
||||
authorsMap={authorsMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Voter Row ──── */
|
||||
|
||||
interface VoterRowProps {
|
||||
vote: NostrEvent;
|
||||
optionLabelMap: Map<string, string>;
|
||||
pollType: string;
|
||||
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
||||
}
|
||||
|
||||
function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps) {
|
||||
// Use batch-fetched author data if available, fall back to individual fetch
|
||||
const individualAuthor = useAuthor(authorsMap?.has(vote.pubkey) ? undefined : vote.pubkey);
|
||||
const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data;
|
||||
const metadata = authorData?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(vote.pubkey);
|
||||
|
||||
const nevent = useMemo(
|
||||
() => nip19.neventEncode({ id: vote.id, author: vote.pubkey }),
|
||||
[vote.id, vote.pubkey],
|
||||
);
|
||||
|
||||
// Resolve which option(s) this person voted for
|
||||
const votedOptions = useMemo(() => {
|
||||
const responseTags = vote.tags.filter(([n]) => n === 'response');
|
||||
if (pollType === 'singlechoice') {
|
||||
const id = responseTags[0]?.[1];
|
||||
const label = id ? optionLabelMap.get(id) : undefined;
|
||||
return label ? [label] : [];
|
||||
}
|
||||
const labels: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [, id] of responseTags) {
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
const label = optionLabelMap.get(id);
|
||||
if (label) labels.push(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [vote.tags, pollType, optionLabelMap]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${nevent}`}
|
||||
onClick={() => {
|
||||
// Close any open dialogs by dispatching escape
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-sm truncate">
|
||||
{authorData?.event ? (
|
||||
<EmojifiedText tags={authorData.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
{metadata?.nip05 && (
|
||||
<VerifiedNip05Text nip05={metadata.nip05} pubkey={vote.pubkey} className="text-xs text-muted-foreground truncate" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{votedOptions.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{votedOptions.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(vote.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
import QRCode from 'qrcode';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getContentWarning } from '@/lib/contentWarning';
|
||||
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
|
||||
@@ -23,6 +25,12 @@ import { parseDimToAspectRatio } from '@/components/MediaCollage';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
|
||||
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
|
||||
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
|
||||
|
||||
/** Maximum number of media tiles shown in the sidebar. */
|
||||
const SIDEBAR_MEDIA_LIMIT = 9;
|
||||
|
||||
/** Simple email regex for display purposes. */
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
@@ -65,10 +73,8 @@ interface ProfileField {
|
||||
|
||||
interface ProfileRightSidebarProps {
|
||||
fields?: ProfileField[];
|
||||
/** Media events fetched via a dedicated search query (video:true image:true). */
|
||||
mediaEvents?: NostrEvent[];
|
||||
/** Whether the media events are still loading. */
|
||||
mediaLoading?: boolean;
|
||||
/** Pubkey whose media-native events to display in the sidebar. */
|
||||
pubkey?: string;
|
||||
/** Called when a media tile is clicked. If provided, tiles don't navigate. */
|
||||
onMediaClick?: (url: string) => void;
|
||||
/** Override the root element's className (e.g. to show on mobile). */
|
||||
@@ -485,20 +491,49 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
const { config } = useAppContext();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Single query: fetch media-native events, then fill remaining slots with kind 1 media if needed.
|
||||
const { data: sidebarEvents, isPending: mediaLoading } = useQuery({
|
||||
queryKey: ['sidebar-media', pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const primaryEvents = await nostr.query(
|
||||
[{ kinds: SIDEBAR_MEDIA_KINDS, authors: [pubkey!], limit: SIDEBAR_MEDIA_LIMIT }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
const primary = primaryEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
// Only fetch kind 1 fallback if there aren't enough media-native events.
|
||||
if (primary.length >= SIDEBAR_MEDIA_LIMIT) return primary;
|
||||
|
||||
const fallbackEvents = await nostr.query(
|
||||
[{ kinds: [1], authors: [pubkey!], search: 'media:true', limit: SIDEBAR_MEDIA_LIMIT } as { kinds: number[]; authors: string[]; search: string; limit: number }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
const fallback = fallbackEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return [...primary, ...fallback];
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const media = useMemo(
|
||||
() => extractMedia(mediaEvents ?? [], config.contentWarningPolicy),
|
||||
[mediaEvents, config.contentWarningPolicy],
|
||||
() => extractMedia(sidebarEvents ?? [], config.contentWarningPolicy),
|
||||
[sidebarEvents, config.contentWarningPolicy],
|
||||
);
|
||||
const mediaLoading = mediaLoadingProp ?? false;
|
||||
|
||||
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
|
||||
|
||||
return (
|
||||
<aside className={cn("w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
|
||||
{/* Media Section — only shown when mediaEvents prop is provided */}
|
||||
{mediaEvents !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
{/* Media Section — only shown when pubkey prop is provided */}
|
||||
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
|
||||
{mediaLoading ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -608,7 +643,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
|
||||
)}
|
||||
|
||||
{/* Footer — hidden when used as a fields-only preview */}
|
||||
{mediaEvents !== undefined && <LinkFooter />}
|
||||
{pubkey !== undefined && <LinkFooter />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
|
||||
import { isCustomEmoji } from '@/lib/customEmoji';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
@@ -212,12 +213,10 @@ export function QuickReactMenu({
|
||||
title={`React with ${isCustom ? shortcode : emoji}`}
|
||||
>
|
||||
{customUrl ? (
|
||||
<img
|
||||
src={customUrl}
|
||||
alt={emoji}
|
||||
<CustomEmojiImg
|
||||
name={shortcode ?? emoji}
|
||||
url={customUrl}
|
||||
className="size-6 object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
emoji
|
||||
|
||||
@@ -12,6 +12,10 @@ interface ReplyContextProps {
|
||||
pubkeys: string[];
|
||||
/** Hex event ID of the parent post being replied to. */
|
||||
parentEventId?: string;
|
||||
/** Relay URL hint for fetching the parent event. */
|
||||
parentRelayHint?: string;
|
||||
/** Author pubkey hint for NIP-65 outbox resolution of the parent event. */
|
||||
parentAuthorHint?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +24,7 @@ interface ReplyContextProps {
|
||||
* When parentEventId is provided, hovering over the line shows an embedded preview of the parent post.
|
||||
* Used consistently across NoteCard and notification views.
|
||||
*/
|
||||
export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContextProps) {
|
||||
export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAuthorHint, className }: ReplyContextProps) {
|
||||
// Filter out any undefined/empty pubkeys defensively
|
||||
const validPubkeys = pubkeys.filter(Boolean);
|
||||
// Show max 2 authors for cleaner UI
|
||||
@@ -38,7 +42,13 @@ export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContext
|
||||
className="w-80 p-0 rounded-2xl shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EmbeddedNote eventId={parentEventId} className="border-0 rounded-none" disableHoverCards />
|
||||
<EmbeddedNote
|
||||
eventId={parentEventId}
|
||||
relays={parentRelayHint ? [parentRelayHint] : undefined}
|
||||
authorHint={parentAuthorHint}
|
||||
className="border-0 rounded-none"
|
||||
disableHoverCards
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
|
||||
@@ -255,7 +255,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="mt-2 space-y-2.5">
|
||||
<div className="space-y-2.5">
|
||||
{/* Header: icon + name + summary */}
|
||||
<div className="flex items-start gap-3">
|
||||
{icon ? (
|
||||
@@ -326,7 +326,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
|
||||
|
||||
// Full detail view
|
||||
return (
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Header: icon + name + summary */}
|
||||
<div className="flex items-start gap-4">
|
||||
{icon ? (
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Package,
|
||||
Download,
|
||||
Tag,
|
||||
Hash,
|
||||
Smartphone,
|
||||
Monitor,
|
||||
Globe,
|
||||
Shield,
|
||||
ExternalLink,
|
||||
GitCommit,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useMemo } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
/** Sanitize schema allowing only the subset needed for a CHANGELOG. */
|
||||
const CHANGELOG_SANITIZE_SCHEMA = {
|
||||
...defaultSchema,
|
||||
tagNames: ['h1', 'h2', 'h3', 'ul', 'ol', 'li', 'p', 'strong', 'em', 'code', 'br'] as string[],
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
/** Get a tag value by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
/** Get all tag entries for a tag name. */
|
||||
function getAllTagEntries(tags: string[][], name: string): string[][] {
|
||||
return tags.filter(([n]) => n === name);
|
||||
}
|
||||
|
||||
/** Get all values for a tag name. */
|
||||
function getAllTags(tags: string[][], name: string): string[] {
|
||||
return tags.filter(([n]) => n === name).map(([, v]) => v);
|
||||
}
|
||||
|
||||
/** Map a MIME type to a human-readable platform label. */
|
||||
function mimeToLabel(mime: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'application/vnd.android.package-archive': 'Android APK',
|
||||
'application/vnd.apple.ipa': 'iOS IPA',
|
||||
'application/x-apple-diskimage': 'macOS DMG',
|
||||
'application/vnd.apple.installer+xml': 'macOS PKG',
|
||||
'application/x-msi': 'Windows MSI',
|
||||
'application/vnd.appimage': 'Linux AppImage',
|
||||
'application/vnd.flatpak': 'Linux Flatpak',
|
||||
'application/x-executable': 'Linux Binary',
|
||||
'application/x-mach-binary': 'macOS Binary',
|
||||
'application/vnd.microsoft.portable-executable': 'Windows EXE',
|
||||
'application/vsix': 'VS Code Extension',
|
||||
'application/x-chrome-extension': 'Chrome Extension',
|
||||
'application/x-xpinstall': 'Firefox Extension',
|
||||
'application/wasm': 'WebAssembly',
|
||||
'application/webbundle': 'Web Bundle',
|
||||
'application/vnd.oci.image.manifest.v1+json': 'OCI Image',
|
||||
};
|
||||
return map[mime] ?? mime;
|
||||
}
|
||||
|
||||
/** Return a platform icon component for a MIME type. */
|
||||
function PlatformIcon({ mime, className }: { mime: string; className?: string }) {
|
||||
if (mime.includes('android') || mime.includes('apple.ipa')) {
|
||||
return <Smartphone className={className} />;
|
||||
}
|
||||
if (mime.includes('apple') || mime.includes('mach') || mime.includes('msi') || mime.includes('portable-executable')) {
|
||||
return <Monitor className={className} />;
|
||||
}
|
||||
if (mime.includes('appimage') || mime.includes('flatpak') || mime.includes('executable')) {
|
||||
return <Monitor className={className} />;
|
||||
}
|
||||
if (mime.includes('wasm') || mime.includes('webbundle') || mime.includes('chrome') || mime.includes('xpinstall') || mime.includes('vsix')) {
|
||||
return <Globe className={className} />;
|
||||
}
|
||||
return <Package className={className} />;
|
||||
}
|
||||
|
||||
/** Format file size for display. */
|
||||
function formatSize(bytes: string | undefined): string | undefined {
|
||||
if (!bytes) return undefined;
|
||||
const n = parseInt(bytes, 10);
|
||||
if (isNaN(n)) return bytes;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
/** Map platform identifier to OS label. */
|
||||
function platformLabel(f: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'android-arm64-v8a': 'ARM64',
|
||||
'android-armeabi-v7a': 'ARMv7',
|
||||
'android-x86': 'x86',
|
||||
'android-x86_64': 'x64',
|
||||
'darwin-arm64': 'Apple Silicon',
|
||||
'darwin-x86_64': 'Intel',
|
||||
'linux-aarch64': 'ARM64',
|
||||
'linux-x86_64': 'x64',
|
||||
'linux-armv7l': 'ARMv7',
|
||||
'linux-riscv64': 'RISC-V',
|
||||
'windows-aarch64': 'ARM64',
|
||||
'windows-x86_64': 'x64',
|
||||
'ios-arm64': 'ARM64',
|
||||
'wasm32': 'WASM32',
|
||||
'wasm64': 'WASM64',
|
||||
'wasi-wasm32': 'WASI',
|
||||
'wasi-wasm64': 'WASI64',
|
||||
};
|
||||
return map[f] ?? f;
|
||||
}
|
||||
|
||||
/** Channel label with color. */
|
||||
function ChannelBadge({ channel }: { channel: string }) {
|
||||
const variants: Record<string, string> = {
|
||||
main: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
beta: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
nightly: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
dev: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
};
|
||||
const colorClass = variants[channel] ?? 'bg-muted text-muted-foreground';
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}>
|
||||
{channel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook to fetch asset events (kind 3063) for a release. */
|
||||
function useReleaseAssets(assetIds: string[]) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent[]>({
|
||||
queryKey: ['zapstore-assets', ...assetIds.sort()],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (assetIds.length === 0) return [];
|
||||
try {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
// Try the Zapstore relay first
|
||||
const events = await nostr.relay(ZAPSTORE_RELAY).query(
|
||||
[{ kinds: [3063], ids: assetIds }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
if (events.length > 0) return events;
|
||||
// Fallback to the default pool
|
||||
const fallbackSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
|
||||
const fallback = await nostr.query(
|
||||
[{ kinds: [3063], ids: assetIds }],
|
||||
{ signal: fallbackSignal },
|
||||
);
|
||||
return fallback;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: assetIds.length > 0,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Hook to fetch the linked app event (kind 32267) for a release. */
|
||||
function useReleaseApp(appIdentifier: string | undefined, releasePubkey: string) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent | null>({
|
||||
queryKey: ['zapstore-app-for-release', appIdentifier, releasePubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!appIdentifier) return null;
|
||||
try {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
|
||||
const events = await nostr.relay(ZAPSTORE_RELAY).query(
|
||||
[{ kinds: [32267], authors: [releasePubkey], '#d': [appIdentifier], limit: 1 }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
if (events.length > 0) return events[0];
|
||||
const fallbackSignal = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
|
||||
const fallback = await nostr.query(
|
||||
[{ kinds: [32267], authors: [releasePubkey], '#d': [appIdentifier], limit: 1 }],
|
||||
{ signal: fallbackSignal },
|
||||
);
|
||||
return fallback.length > 0 ? fallback[0] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!appIdentifier,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Single asset download row. */
|
||||
function AssetRow({ event }: { event: NostrEvent }) {
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const url = getTag(event.tags, 'url');
|
||||
const version = getTag(event.tags, 'version');
|
||||
const size = formatSize(getTag(event.tags, 'size'));
|
||||
const platforms = getAllTags(event.tags, 'f');
|
||||
const variant = getTag(event.tags, 'variant');
|
||||
const commit = getTag(event.tags, 'commit');
|
||||
const hash = getTag(event.tags, 'x');
|
||||
|
||||
const label = mimeToLabel(mime);
|
||||
const platformLabels = platforms.map(platformLabel);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (url) {
|
||||
await openUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-muted/50 transition-colors group">
|
||||
{/* Platform icon */}
|
||||
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<PlatformIcon mime={mime} className="size-4 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium truncate">{label}</span>
|
||||
{variant && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
{variant}
|
||||
</Badge>
|
||||
)}
|
||||
{platformLabels.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{platformLabels.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{version && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Tag className="size-3" />
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
{size && (
|
||||
<span className="text-xs text-muted-foreground">{size}</span>
|
||||
)}
|
||||
{commit && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<GitCommit className="size-3" />
|
||||
<code className="font-mono">{commit.slice(0, 7)}</code>
|
||||
</span>
|
||||
)}
|
||||
{hash && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Hash className="size-3" />
|
||||
<code className="font-mono">{hash.slice(0, 8)}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download button */}
|
||||
{url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => { e.stopPropagation(); handleDownload(); }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ZapstoreReleaseContentProps {
|
||||
event: NostrEvent;
|
||||
/** If true, show compact preview (used in NoteCard feed). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** Renders a kind 30063 Zapstore release event. */
|
||||
export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseContentProps) {
|
||||
const version = getTag(event.tags, 'version');
|
||||
const channel = getTag(event.tags, 'c') ?? 'main';
|
||||
const appIdentifier = getTag(event.tags, 'i');
|
||||
|
||||
// Collect asset event IDs from `e` tags
|
||||
const assetEntries = useMemo(() => getAllTagEntries(event.tags, 'e'), [event.tags]);
|
||||
const assetIds = useMemo(() => assetEntries.map(([, id]) => id).filter(Boolean), [assetEntries]);
|
||||
|
||||
const { data: assets = [], isLoading: assetsLoading } = useReleaseAssets(assetIds);
|
||||
const { data: appEvent } = useReleaseApp(appIdentifier, event.pubkey);
|
||||
|
||||
const appName = appEvent
|
||||
? (getTag(appEvent.tags, 'name') || getTag(appEvent.tags, 'd') || appIdentifier)
|
||||
: appIdentifier;
|
||||
const appIcon = appEvent ? getTag(appEvent.tags, 'icon') : undefined;
|
||||
const appId = appEvent ? getTag(appEvent.tags, 'd') : appIdentifier;
|
||||
|
||||
// Build naddr link to the app event if we have it
|
||||
const appNaddr = appEvent
|
||||
? nip19.naddrEncode({ kind: 32267, pubkey: appEvent.pubkey, identifier: getTag(appEvent.tags, 'd') ?? '' })
|
||||
: undefined;
|
||||
|
||||
const releaseNotes = event.content;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{/* Header: icon + app name + version */}
|
||||
<div className="flex items-start gap-3">
|
||||
{appIcon ? (
|
||||
<img
|
||||
src={appIcon}
|
||||
alt={appName ?? ''}
|
||||
className="size-10 rounded-xl object-cover shrink-0 shadow-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLElement).style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Package className="size-5 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{appName && (
|
||||
appNaddr ? (
|
||||
<Link
|
||||
to={`/${appNaddr}`}
|
||||
className="font-semibold text-[15px] leading-snug hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{appName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-semibold text-[15px] leading-snug">{appName}</span>
|
||||
)
|
||||
)}
|
||||
{version && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-0">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
<ChannelBadge channel={channel} />
|
||||
</div>
|
||||
{/* Asset count summary */}
|
||||
{assetIds.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{assetIds.length} {assetIds.length === 1 ? 'asset' : 'assets'} available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Release notes — rendered as Markdown, clamped to 4 lines */}
|
||||
{releaseNotes && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm leading-relaxed text-muted-foreground line-clamp-4
|
||||
[&_h1]:text-sm [&_h1]:font-semibold
|
||||
[&_h2]:text-sm [&_h2]:font-semibold
|
||||
[&_h3]:text-sm [&_h3]:font-semibold
|
||||
[&_ul]:pl-4 [&_ul]:list-disc
|
||||
[&_ol]:pl-4 [&_ol]:list-decimal
|
||||
[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono
|
||||
[&_p]:my-0 [&_li]:my-0 [&_h1]:my-0 [&_h2]:my-0 [&_h3]:my-0">
|
||||
<Markdown rehypePlugins={[[rehypeSanitize, CHANGELOG_SANITIZE_SCHEMA]]}>
|
||||
{releaseNotes}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full detail view
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
{appIcon ? (
|
||||
<img
|
||||
src={appIcon}
|
||||
alt={appName ?? ''}
|
||||
className="size-14 rounded-2xl object-cover shrink-0 shadow-md"
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLElement).style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Package className="size-7 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{appName && (
|
||||
appNaddr ? (
|
||||
<Link
|
||||
to={`/${appNaddr}`}
|
||||
className="text-lg font-bold leading-snug hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{appName}
|
||||
</Link>
|
||||
) : (
|
||||
<h2 className="text-lg font-bold leading-snug">{appName}</h2>
|
||||
)
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap mt-1.5">
|
||||
{version && (
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
<ChannelBadge channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action row */}
|
||||
{appId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="gap-1.5" asChild>
|
||||
<a
|
||||
href={`https://zapstore.dev/apps/${encodeURIComponent(appId)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
View on Zapstore
|
||||
</a>
|
||||
</Button>
|
||||
{appNaddr && (
|
||||
<Button size="sm" variant="ghost" className="gap-1.5" asChild>
|
||||
<Link to={`/${appNaddr}`} onClick={(e) => e.stopPropagation()}>
|
||||
<Package className="size-3.5" />
|
||||
App details
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Release notes */}
|
||||
{releaseNotes && (
|
||||
<div className="rounded-xl border border-border bg-muted/30 p-4 space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Release Notes
|
||||
</p>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm leading-relaxed
|
||||
[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1
|
||||
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1
|
||||
[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1
|
||||
[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc
|
||||
[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal
|
||||
[&_li]:my-0.5
|
||||
[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono
|
||||
[&_p]:my-1
|
||||
first:[&>*]:mt-0">
|
||||
<Markdown rehypePlugins={[[rehypeSanitize, CHANGELOG_SANITIZE_SCHEMA]]}>
|
||||
{releaseNotes}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assets */}
|
||||
{assetIds.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground px-1">
|
||||
Downloads
|
||||
</p>
|
||||
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
|
||||
{assetsLoading
|
||||
? Array.from({ length: Math.min(assetIds.length, 3) }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Skeleton className="size-8 rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: assets.length > 0
|
||||
? assets.map((asset) => (
|
||||
<AssetRow key={asset.id} event={asset} />
|
||||
))
|
||||
: assetIds.map((id) => (
|
||||
<div key={id} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="size-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Package className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{id.slice(0, 16)}…</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton loading state for ZapstoreReleaseContent. */
|
||||
export function ZapstoreReleaseSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="size-14 rounded-2xl shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-12 rounded-full" />
|
||||
<Skeleton className="h-5 w-10 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-36 rounded-md" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<Skeleton className="size-8 rounded-lg shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// kind 3063 — Software Asset card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ZapstoreAssetContentProps {
|
||||
event: NostrEvent;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** Renders a kind 3063 Zapstore software asset event. */
|
||||
export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentProps) {
|
||||
const mime = getTag(event.tags, 'm') ?? '';
|
||||
const url = getTag(event.tags, 'url');
|
||||
const version = getTag(event.tags, 'version');
|
||||
const size = formatSize(getTag(event.tags, 'size'));
|
||||
const appIdentifier = getTag(event.tags, 'i');
|
||||
const platforms = getAllTags(event.tags, 'f');
|
||||
const variant = getTag(event.tags, 'variant');
|
||||
const commit = getTag(event.tags, 'commit');
|
||||
const hash = getTag(event.tags, 'x');
|
||||
const supportedNips = getAllTags(event.tags, 'supported_nip');
|
||||
const minPlatformVersion = getTag(event.tags, 'min_platform_version');
|
||||
|
||||
const label = mimeToLabel(mime);
|
||||
const platformLabels = platforms.map(platformLabel);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (url) {
|
||||
await openUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<PlatformIcon mime={mime} className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-[15px] leading-snug">{label}</span>
|
||||
{variant && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">{variant}</Badge>
|
||||
)}
|
||||
{version && (
|
||||
<span className="text-xs text-muted-foreground">v{version}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground flex-wrap">
|
||||
{appIdentifier && <span>{appIdentifier}</span>}
|
||||
{platformLabels.length > 0 && <span>{platformLabels.join(', ')}</span>}
|
||||
{size && <span>{size}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<PlatformIcon mime={mime} className="size-7 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold leading-snug">{label}</h2>
|
||||
{appIdentifier && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{appIdentifier}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||
{version && (
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
{variant && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-0">{variant}</Badge>
|
||||
)}
|
||||
{platformLabels.length > 0 && (
|
||||
platformLabels.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs px-2 py-0">{p}</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download button */}
|
||||
{url && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={(e) => { e.stopPropagation(); handleDownload(); }}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Metadata grid */}
|
||||
<div className="rounded-xl border border-border divide-y divide-border">
|
||||
{size && (
|
||||
<MetaRow label="File Size" value={size} />
|
||||
)}
|
||||
{mime && (
|
||||
<MetaRow label="MIME Type" value={<code className="text-xs font-mono">{mime}</code>} />
|
||||
)}
|
||||
{hash && (
|
||||
<MetaRow label="SHA-256" value={<code className="text-xs font-mono break-all">{hash}</code>} />
|
||||
)}
|
||||
{commit && (
|
||||
<MetaRow
|
||||
label="Commit"
|
||||
value={
|
||||
<span className="flex items-center gap-1">
|
||||
<GitCommit className="size-3 shrink-0" />
|
||||
<code className="text-xs font-mono">{commit}</code>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{minPlatformVersion && (
|
||||
<MetaRow label="Min Platform Version" value={minPlatformVersion} />
|
||||
)}
|
||||
{supportedNips.length > 0 && (
|
||||
<MetaRow
|
||||
label="Supported NIPs"
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{supportedNips.map((nip) => (
|
||||
<Badge key={nip} variant="secondary" className="text-xs px-1.5 py-0">
|
||||
NIP-{nip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Certificate hashes (Android) */}
|
||||
{getAllTags(event.tags, 'apk_certificate_hash').length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
APK Certificate
|
||||
</p>
|
||||
{getAllTags(event.tags, 'apk_certificate_hash').map((hash) => (
|
||||
<div key={hash} className="flex items-center gap-2">
|
||||
<Shield className="size-3.5 text-green-600 shrink-0" />
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">{hash}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single metadata row inside the asset details grid. */
|
||||
function MetaRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground w-36 shrink-0 pt-0.5">{label}</span>
|
||||
<span className="text-sm flex-1 min-w-0">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton for ZapstoreAssetContent. */
|
||||
export function ZapstoreAssetSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="size-14 rounded-2xl shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-28 rounded-md" />
|
||||
<div className="rounded-xl border border-border divide-y divide-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-start gap-4 px-3 py-2">
|
||||
<Skeleton className="h-3 w-28 mt-0.5 shrink-0" />
|
||||
<Skeleton className="h-3 w-48 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export Separator so it's available if needed
|
||||
export { Separator };
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
HardDrive,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import slugify from 'slugify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { FabButton } from '@/components/FabButton';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
@@ -43,6 +45,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDrafts, type Draft } from '@/hooks/useDrafts';
|
||||
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
|
||||
import { useKeyboardVisible } from '@/hooks/useKeyboardVisible';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { saveDraft as saveLocalDraft, deleteDraftBySlug, deleteLocalDraftById, getLocalDrafts } from '@/lib/localDrafts';
|
||||
import type { ArticleFields } from '@/lib/articleHelpers';
|
||||
import { MilkdownEditor } from './MilkdownEditor';
|
||||
@@ -64,7 +68,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
|
||||
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, isSaving: isSyncingToRelay, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
|
||||
const { articles: publishedArticles } = usePublishedArticles();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -79,9 +83,15 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(editMode);
|
||||
const [originalSlug, setOriginalSlug] = useState<string | null>(
|
||||
editMode && initialData?.slug ? initialData.slug : null,
|
||||
);
|
||||
const [originalPublishedAt, setOriginalPublishedAt] = useState<number | null>(
|
||||
initialData?.publishedAt ?? null,
|
||||
);
|
||||
const [metadataExpanded, setMetadataExpanded] = useState(false);
|
||||
const keyboardVisible = useKeyboardVisible();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [article, setArticle] = useState<ArticleData>({
|
||||
title: initialData?.title || '',
|
||||
@@ -99,35 +109,35 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => () => { mountedRef.current = false; }, []);
|
||||
|
||||
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save. */
|
||||
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save.
|
||||
* Always saves locally first so the draft appears immediately in "My Articles",
|
||||
* then syncs to the relay in the background. */
|
||||
const persistDraft = useCallback(async (data: ArticleData, { silent }: { silent?: boolean } = {}) => {
|
||||
// Always persist locally so the draft is visible immediately
|
||||
saveLocalDraft(data);
|
||||
setLocalDrafts(getLocalDrafts());
|
||||
|
||||
// Mark as saved immediately after the local write — the relay sync
|
||||
// happens in the background and shouldn't leave the "unsaved" dot visible.
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
await saveRelayDraft(data);
|
||||
if (!mountedRef.current) return;
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
if (!silent) {
|
||||
toast({ title: 'Draft saved', description: 'Your article has been saved to Nostr relays.' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft to relay:', error);
|
||||
saveLocalDraft(data);
|
||||
if (!mountedRef.current) return;
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
if (!silent) {
|
||||
toast({ title: 'Draft saved locally', description: 'Could not sync to relays. Saved to your browser.', variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saveLocalDraft(data);
|
||||
if (!mountedRef.current) return;
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
if (!silent) {
|
||||
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
|
||||
}
|
||||
} else if (!silent) {
|
||||
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
|
||||
}
|
||||
}, [user, saveRelayDraft]);
|
||||
|
||||
@@ -149,6 +159,13 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
};
|
||||
}, [hasUnsavedChanges, persistDraft]);
|
||||
|
||||
/** Silently save the current draft on blur — uses the ref so it's always current. */
|
||||
const handleBlurSave = useCallback(() => {
|
||||
const current = articleRef.current;
|
||||
if (!current.title && !current.content) return;
|
||||
persistDraft(current, { silent: true });
|
||||
}, [persistDraft]);
|
||||
|
||||
// Reference to handlers for keyboard shortcuts
|
||||
const handlePublishRef = useRef<(() => void) | null>(null);
|
||||
const handleSaveDraftRef = useRef<(() => void) | null>(null);
|
||||
@@ -221,6 +238,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
});
|
||||
slugManuallyEdited.current = !!item.slug;
|
||||
setIsEditMode(isPublishedArticle);
|
||||
setOriginalSlug(isPublishedArticle ? item.slug : null);
|
||||
setOriginalPublishedAt(item.publishedAt ?? null);
|
||||
setHasUnsavedChanges(false);
|
||||
setActiveTab('write');
|
||||
@@ -419,10 +437,11 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
return;
|
||||
}
|
||||
|
||||
// In edit mode we're intentionally overwriting, so skip the collision check
|
||||
if (!isEditMode) {
|
||||
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
|
||||
|
||||
// Collision check: only block when the slug would overwrite a *different*
|
||||
// published article. When editing an existing article with the same slug
|
||||
// we're intentionally updating it, so skip the check.
|
||||
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
|
||||
if (slug !== originalSlug) {
|
||||
try {
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
@@ -442,7 +461,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
}
|
||||
|
||||
doPublish();
|
||||
}, [user, article, isEditMode, nostr, doPublish]);
|
||||
}, [user, article, originalSlug, nostr, doPublish]);
|
||||
|
||||
// Set refs for keyboard shortcuts
|
||||
handlePublishRef.current = handlePublish;
|
||||
@@ -496,7 +515,10 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
Editing
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-blue-600 dark:text-blue-400 text-sm">Editing</span>
|
||||
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400 text-sm">
|
||||
<Cloud className={`size-3.5 ${isSyncingToRelay ? 'animate-pulse' : ''}`} />
|
||||
Editing
|
||||
</span>
|
||||
)
|
||||
) : hasUnsavedChanges ? (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -504,16 +526,19 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
Unsaved
|
||||
</span>
|
||||
) : lastSaved ? (
|
||||
<span className="text-sm text-muted-foreground">Saved</span>
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Cloud className={`size-3.5 ${isSyncingToRelay ? 'animate-pulse' : ''}`} />
|
||||
Saved
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const totalDrafts = combinedDrafts.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Sticky header */}
|
||||
<div className="sticky top-0 z-20">
|
||||
<SubHeaderBar pinned className="relative !top-0">
|
||||
{/* Header — not sticky on mobile in write mode so it scrolls away with content */}
|
||||
<div className={isMobile && activeTab === 'write' ? 'relative z-20' : 'sticky top-0 z-20'}>
|
||||
<SubHeaderBar pinned className={isMobile && activeTab === 'write' ? 'relative !static' : 'relative !top-0'}>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
@@ -534,6 +559,8 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
/>
|
||||
</SubHeaderBar>
|
||||
</div>
|
||||
{/* Spacer for the arc overhang */}
|
||||
<div style={{ height: ARC_OVERHANG_PX }} />
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -544,166 +571,259 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
/>
|
||||
{/* ── New article tab ──────────────────────────────────────── */}
|
||||
{activeTab === 'write' && (
|
||||
<div className="px-4 py-6 pb-24 space-y-6">
|
||||
{/* Header Image */}
|
||||
{article.image ? (
|
||||
<div className="relative rounded-xl overflow-hidden group">
|
||||
<img
|
||||
src={article.image}
|
||||
alt="Header"
|
||||
className="w-full h-48 sm:h-64 object-cover"
|
||||
/>
|
||||
{/* Desktop: centered overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
<div className={`px-4 pb-24 space-y-4 sm:space-y-6 ${keyboardVisible ? 'py-2' : 'py-4 sm:py-6'}`}>
|
||||
{/* Header Image — hide when keyboard is visible on mobile */}
|
||||
{!(isMobile && keyboardVisible) && (
|
||||
<>
|
||||
{article.image ? (
|
||||
<div className="relative rounded-xl overflow-hidden group">
|
||||
<img
|
||||
src={article.image}
|
||||
alt="Header"
|
||||
className="w-full h-48 sm:h-64 object-cover"
|
||||
/>
|
||||
{/* Desktop: centered overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Image className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Change Image
|
||||
</Button>
|
||||
</div>
|
||||
{/* Mobile: persistent corner button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Image className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
<Loader2 className="w-8 h-8 animate-spin mb-2" />
|
||||
) : (
|
||||
<Image className="w-4 h-4 mr-2" />
|
||||
<Image className="w-8 h-8 mb-2" />
|
||||
)}
|
||||
Change Image
|
||||
</Button>
|
||||
</div>
|
||||
{/* Mobile: persistent corner button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Image className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-8 h-8 animate-spin mb-2" />
|
||||
) : (
|
||||
<Image className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm">Add a header image</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="text-sm">Add a header image</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{/* Title — always visible, slightly smaller when keyboard is up on mobile */}
|
||||
<input
|
||||
type="text"
|
||||
value={article.title}
|
||||
onChange={(e) => updateArticle('title', e.target.value)}
|
||||
onBlur={handleBlurSave}
|
||||
placeholder="Your article title..."
|
||||
className="w-full text-3xl sm:text-4xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40"
|
||||
className={`w-full font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40 ${
|
||||
isMobile && keyboardVisible ? 'text-xl' : 'text-3xl sm:text-4xl'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Metadata — inline between title and body */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="summary" className="text-muted-foreground text-sm">Summary</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
value={article.summary}
|
||||
onChange={(e) => updateArticle('summary', e.target.value)}
|
||||
placeholder="A brief description of your article..."
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
{/* Metadata — collapsible on mobile, always expanded on desktop */}
|
||||
{isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMetadataExpanded((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${metadataExpanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||
<span>Details</span>
|
||||
{(article.summary || article.tags.length > 0) && (
|
||||
<span className="text-muted-foreground/60">
|
||||
({[article.summary && 'summary', article.tags.length > 0 && `${article.tags.length} tags`].filter(Boolean).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{metadataExpanded && (
|
||||
<div className="space-y-3 animate-in slide-in-from-top-1 duration-200">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="summary" className="text-muted-foreground text-xs">Summary</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
value={article.summary}
|
||||
onChange={(e) => updateArticle('summary', e.target.value)}
|
||||
placeholder="A brief description of your article..."
|
||||
rows={2}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={article.slug}
|
||||
onChange={(e) => {
|
||||
slugManuallyEdited.current = true;
|
||||
updateArticle('slug', e.target.value);
|
||||
}}
|
||||
placeholder="article-url-slug"
|
||||
className="h-8 font-mono text-xs"
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={article.slug}
|
||||
onChange={(e) => {
|
||||
slugManuallyEdited.current = true;
|
||||
updateArticle('slug', e.target.value);
|
||||
}}
|
||||
placeholder="article-url-slug"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
|
||||
<Hash className="w-3 h-3 shrink-0" />
|
||||
Tags
|
||||
</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
|
||||
}
|
||||
placeholder="Add a tag..."
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
|
||||
<span className="text-base leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{article.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{article.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-0.5 text-xs">
|
||||
#{tag}
|
||||
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="summary" className="text-muted-foreground text-sm">Summary</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
value={article.summary}
|
||||
onChange={(e) => updateArticle('summary', e.target.value)}
|
||||
placeholder="A brief description of your article..."
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
|
||||
<Hash className="w-3 h-3 shrink-0" />
|
||||
Tags
|
||||
</Label>
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
|
||||
}
|
||||
placeholder="Add a tag..."
|
||||
className="h-8 text-xs flex-1"
|
||||
id="slug"
|
||||
value={article.slug}
|
||||
onChange={(e) => {
|
||||
slugManuallyEdited.current = true;
|
||||
updateArticle('slug', e.target.value);
|
||||
}}
|
||||
placeholder="article-url-slug"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
|
||||
<span className="text-base leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
|
||||
<Hash className="w-3 h-3 shrink-0" />
|
||||
Tags
|
||||
</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
|
||||
}
|
||||
placeholder="Add a tag..."
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
|
||||
<span className="text-base leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{article.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{article.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
|
||||
#{tag}
|
||||
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{article.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{article.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
|
||||
#{tag}
|
||||
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<MilkdownEditor
|
||||
value={article.content}
|
||||
onChange={(value) => updateArticle('content', value || '')}
|
||||
onBlur={handleBlurSave}
|
||||
onUploadImage={handleImageUpload}
|
||||
placeholder="Start writing your article..."
|
||||
className="rounded-xl border border-border bg-card min-h-[250px] sm:min-h-[400px]"
|
||||
className={`rounded-xl border border-border bg-card ${
|
||||
isMobile && keyboardVisible ? 'min-h-[150px]' : 'min-h-[250px] sm:min-h-[400px]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Stats + Save */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<span className="shrink-0">{wordCount} words</span>
|
||||
<span>·</span>
|
||||
<span className="shrink-0">{readingTime} min read</span>
|
||||
{statusLabel && (
|
||||
<>
|
||||
<span>·</span>
|
||||
{statusLabel}
|
||||
</>
|
||||
)}
|
||||
{/* Stats + Save — hide when keyboard is visible on mobile */}
|
||||
{!(isMobile && keyboardVisible) && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<span className="shrink-0">{wordCount} words</span>
|
||||
<span>·</span>
|
||||
<span className="shrink-0">{readingTime} min read</span>
|
||||
{statusLabel && (
|
||||
<>
|
||||
<span>·</span>
|
||||
{statusLabel}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveDraft}
|
||||
className="rounded-full gap-1.5 shrink-0"
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
Save Draft
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveDraft}
|
||||
className="rounded-full gap-1.5 shrink-0"
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
Save Draft
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -816,18 +936,20 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish FAB — mobile: fixed bottom right */}
|
||||
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
|
||||
<FabButton
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || !user}
|
||||
title={isEditMode ? 'Update article' : 'Publish article'}
|
||||
icon={isPublishing
|
||||
? <Loader2 size={18} className="animate-spin" />
|
||||
: <Send strokeWidth={3} size={18} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Publish FAB — mobile: fixed bottom right, hidden when keyboard is up */}
|
||||
{!keyboardVisible && (
|
||||
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
|
||||
<FabButton
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || !user}
|
||||
title={isEditMode ? 'Update article' : 'Publish article'}
|
||||
icon={isPublishing
|
||||
? <Loader2 size={18} className="animate-spin" />
|
||||
: <Send strokeWidth={3} size={18} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Publish FAB — desktop: sticky within column */}
|
||||
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
|
||||
<div className="flex justify-end pr-4">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { LinkDialog } from './LinkDialog';
|
||||
interface MilkdownEditorInnerProps {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onBlur?: () => void;
|
||||
onUploadImage?: (file: File) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
showToolbar?: boolean;
|
||||
@@ -22,7 +23,7 @@ interface MilkdownEditorInnerProps {
|
||||
onToggleSource?: () => void;
|
||||
}
|
||||
|
||||
function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
|
||||
function MilkdownEditorInner({ value, onChange, onBlur, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
|
||||
const initialValueRef = useRef(value);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const lastExternalValue = useRef(value);
|
||||
@@ -35,10 +36,12 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
|
||||
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
|
||||
const selectionRef = useRef<{ from: number; to: number } | null>(null);
|
||||
|
||||
// Keep ref updated
|
||||
// Keep refs in sync so Milkdown remounts (e.g. source mode toggle) use
|
||||
// the latest value rather than the stale value captured on first render.
|
||||
useEffect(() => {
|
||||
initialValueRef.current = value;
|
||||
onUploadImageRef.current = onUploadImage;
|
||||
}, [onUploadImage]);
|
||||
}, [value, onUploadImage]);
|
||||
|
||||
const { get } = useEditor((root) => {
|
||||
const editor = Editor.make()
|
||||
@@ -146,15 +149,27 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
|
||||
return () => dom.removeEventListener('blur', check);
|
||||
}, [get]);
|
||||
|
||||
// Handle external value changes (e.g., loading a draft)
|
||||
// Handle external value changes (e.g., loading a draft).
|
||||
// In source mode, just keep lastExternalValue in sync so the guard works
|
||||
// correctly when switching back. When not in source mode, push the new
|
||||
// value into the Milkdown editor via replaceAll.
|
||||
useEffect(() => {
|
||||
if (sourceMode) {
|
||||
// Track textarea changes so we don't needlessly replaceAll on switch-back
|
||||
lastExternalValue.current = value;
|
||||
return;
|
||||
}
|
||||
const editor = get();
|
||||
if (editor && value !== lastExternalValue.current) {
|
||||
// Only update if the value changed externally (not from user typing)
|
||||
editor.action(replaceAll(value));
|
||||
try {
|
||||
editor.action(replaceAll(value));
|
||||
} catch {
|
||||
// editorView may not be ready yet (e.g. first render); ignore
|
||||
return;
|
||||
}
|
||||
lastExternalValue.current = value;
|
||||
}
|
||||
}, [value, get]);
|
||||
}, [value, get, sourceMode]);
|
||||
|
||||
// Handle link dialog open
|
||||
const handleLinkButtonClick = useCallback(() => {
|
||||
@@ -322,6 +337,7 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className="w-full min-h-[250px] sm:min-h-[350px] p-3 bg-transparent font-mono text-sm resize-y outline-none"
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
@@ -329,6 +345,7 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
|
||||
) : (
|
||||
<div
|
||||
className="milkdown-content"
|
||||
onBlur={onBlur}
|
||||
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
|
||||
>
|
||||
<Milkdown />
|
||||
@@ -347,13 +364,14 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, show
|
||||
interface MilkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onBlur?: () => void;
|
||||
onUploadImage?: (file: File) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showToolbar?: boolean;
|
||||
}
|
||||
|
||||
export function MilkdownEditor({ value, onChange, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
|
||||
export function MilkdownEditor({ value, onChange, onBlur, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
|
||||
const [sourceMode, setSourceMode] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -362,6 +380,7 @@ export function MilkdownEditor({ value, onChange, onUploadImage, placeholder, cl
|
||||
<MilkdownEditorInner
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onUploadImage={onUploadImage}
|
||||
placeholder={placeholder}
|
||||
showToolbar={showToolbar}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Sticker, Info } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
|
||||
interface StickerPickerProps {
|
||||
@@ -54,11 +55,10 @@ export function StickerPicker({ onSelect }: StickerPickerProps) {
|
||||
onClick={() => onSelect(emoji)}
|
||||
className="aspect-square rounded-xl overflow-hidden hover:bg-muted/80 transition-all p-1.5 group active:scale-90"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -63,7 +63,7 @@ const AlertDialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,7 +71,7 @@ const DialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -188,7 +188,7 @@ export interface AppConfig {
|
||||
homePage: string;
|
||||
/** Display name used in the NIP-89 "client" tag. Falls back to `appName` when not set. */
|
||||
clientName?: string;
|
||||
/** NIP-89 addr (`31990:<pubkey>:<d-tag>`) identifying this client's handler event. Included as the third element of the "client" tag. */
|
||||
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990:<pubkey>:<d-tag>` addr and relay hint for the "client" tag per NIP-89. */
|
||||
client?: string;
|
||||
/** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
|
||||
magicMouse: boolean;
|
||||
|
||||
@@ -112,6 +112,17 @@ export class LayoutStore {
|
||||
|
||||
export const LayoutStoreContext = createContext<LayoutStore | null>(null);
|
||||
|
||||
/**
|
||||
* Provides the center column DOM element so components deep in the tree can
|
||||
* portal overlays into it (e.g. the nsite preview panel).
|
||||
*/
|
||||
export const CenterColumnContext = createContext<HTMLElement | null>(null);
|
||||
|
||||
/** Hook to get the center column DOM element. Returns null until the layout has mounted. */
|
||||
export function useCenterColumn(): HTMLElement | null {
|
||||
return useContext(CenterColumnContext);
|
||||
}
|
||||
|
||||
/** Context for exposing the scroll-direction hidden state to child components (MobileTopBar, SubHeaderBar). */
|
||||
export const NavHiddenContext = createContext<boolean>(false);
|
||||
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { toast } from './useToast';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
buildMigrationTags,
|
||||
generatePetId10,
|
||||
getCanonicalBlobbiD,
|
||||
migratePetInHas,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbiEvent,
|
||||
parseStorageTags,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
type StorageItem,
|
||||
} from '@/lib/blobbi';
|
||||
|
||||
/**
|
||||
* Result of a successful migration.
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
/** The new canonical d-tag */
|
||||
canonicalD: string;
|
||||
/** The published canonical Blobbi event */
|
||||
event: NostrEvent;
|
||||
/** The parsed canonical BlobbiCompanion */
|
||||
companion: BlobbiCompanion;
|
||||
/** The updated profile event */
|
||||
profileEvent: NostrEvent;
|
||||
/** The updated profile tags (canonical has, current_companion, etc.) */
|
||||
profileTags: string[][];
|
||||
/** The profile storage (unchanged during migration, but fresh from migrated profile) */
|
||||
profileStorage: StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the migration helper.
|
||||
*/
|
||||
export interface EnsureCanonicalOptions {
|
||||
/** The companion to check/migrate */
|
||||
companion: BlobbiCompanion;
|
||||
/** The user's profile */
|
||||
profile: BlobbonautProfile;
|
||||
/** Callback to update the profile event in query cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update the companion event in query cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update localStorage selection if it was pointing to legacy d */
|
||||
updateStoredSelectedD?: (newD: string) => void;
|
||||
/** Callback to invalidate companion query */
|
||||
invalidateCompanion?: () => void;
|
||||
/** Callback to invalidate profile query */
|
||||
invalidateProfile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of ensureCanonicalBlobbiBeforeAction.
|
||||
*/
|
||||
export interface EnsureCanonicalResult {
|
||||
/** Whether the companion was migrated */
|
||||
wasMigrated: boolean;
|
||||
/** The canonical companion (either the original or the migrated one) */
|
||||
companion: BlobbiCompanion;
|
||||
/** The canonical event tags to use for the action */
|
||||
allTags: string[][];
|
||||
/** The event content to use */
|
||||
content: string;
|
||||
/**
|
||||
* The latest profile tags to use for profile updates.
|
||||
* IMPORTANT: Always use these instead of profile.allTags from hook closure
|
||||
* to avoid restoring stale/legacy values after migration.
|
||||
*/
|
||||
profileAllTags: string[][];
|
||||
/**
|
||||
* The latest profile storage to use.
|
||||
* Use this as the base for storage modifications.
|
||||
*/
|
||||
profileStorage: StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing centralized migration logic for Blobbi companions.
|
||||
*
|
||||
* This hook should be used by all action handlers to ensure legacy Blobbis
|
||||
* are automatically migrated before any interaction.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
|
||||
*
|
||||
* const handleFeed = async () => {
|
||||
* const result = await ensureCanonicalBlobbiBeforeAction({
|
||||
* companion,
|
||||
* profile,
|
||||
* updateProfileEvent,
|
||||
* updateCompanionEvent,
|
||||
* updateStoredSelectedD: setStoredSelectedD,
|
||||
* });
|
||||
*
|
||||
* if (!result) return; // Migration failed
|
||||
*
|
||||
* // Continue with the action using result.companion and result.allTags
|
||||
* const newTags = updateBlobbiTags(result.allTags, { ... });
|
||||
* // ... publish event
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function useBlobbiMigration() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Migrate a legacy Blobbi to canonical format.
|
||||
*
|
||||
* This function:
|
||||
* 1. Generates a canonical d-tag
|
||||
* 2. Ensures a seed exists (generates one if missing)
|
||||
* 3. Preserves name, stage, stats, state, timestamps
|
||||
* 4. Publishes a canonical 31124 event
|
||||
* 5. Updates the Blobbonaut profile (kind 11125)
|
||||
* 6. Updates local state (query cache, localStorage)
|
||||
*/
|
||||
const migrateLegacyBlobbi = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<MigrationResult | null> => {
|
||||
const {
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
} = options;
|
||||
|
||||
if (!user?.pubkey) {
|
||||
console.error('[Blobbi Migration] No user pubkey');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[Blobbi Migration] Starting migration for:', companion.d);
|
||||
|
||||
try {
|
||||
// Generate new canonical d-tag
|
||||
const newPetId = generatePetId10();
|
||||
const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId);
|
||||
|
||||
// Build migration tags (preserves name, stage, stats, generates seed if missing)
|
||||
const migrationTags = buildMigrationTags(companion.event, newPetId, user.pubkey);
|
||||
|
||||
console.log('[Blobbi Migration] Publishing canonical event with d:', canonicalD);
|
||||
|
||||
// Publish the canonical Blobbi state
|
||||
const canonicalEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content || `${companion.name} is a ${companion.stage} Blobbi.`,
|
||||
tags: migrationTags,
|
||||
});
|
||||
|
||||
// Parse the new event to get the canonical companion
|
||||
const canonicalCompanion = parseBlobbiEvent(canonicalEvent);
|
||||
if (!canonicalCompanion) {
|
||||
throw new Error('Failed to parse migrated event');
|
||||
}
|
||||
|
||||
// Update profile: replace legacy d with canonical d in has[], update current_companion
|
||||
const updatedHas = migratePetInHas(profile.has, companion.d, canonicalD);
|
||||
const shouldUpdateCurrentCompanion = profile.currentCompanion === companion.d;
|
||||
|
||||
const profileUpdates: Record<string, string | string[]> = {
|
||||
has: updatedHas,
|
||||
};
|
||||
|
||||
if (shouldUpdateCurrentCompanion) {
|
||||
profileUpdates.current_companion = canonicalD;
|
||||
}
|
||||
|
||||
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
|
||||
|
||||
console.log('[Blobbi Migration] Publishing updated profile');
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
// Update query caches
|
||||
updateProfileEvent(profileEvent);
|
||||
updateCompanionEvent(canonicalEvent);
|
||||
|
||||
// Update localStorage selection if it was pointing to legacy d
|
||||
if (updateStoredSelectedD) {
|
||||
console.log('[Blobbi Migration] Updating localStorage selection:', canonicalD);
|
||||
updateStoredSelectedD(canonicalD);
|
||||
}
|
||||
|
||||
// Invalidate queries to refetch fresh data
|
||||
invalidateCompanion?.();
|
||||
invalidateProfile?.();
|
||||
|
||||
toast({
|
||||
title: 'Pet upgraded!',
|
||||
description: `${companion.name} has been migrated to the new format.`,
|
||||
});
|
||||
|
||||
console.log('[Blobbi Migration] Migration complete:', {
|
||||
legacyD: companion.d,
|
||||
canonicalD,
|
||||
});
|
||||
|
||||
// Parse storage from the migrated profile tags
|
||||
// Storage itself doesn't change during migration, but we need fresh tags
|
||||
const migratedStorage = parseStorageTags(profileTags);
|
||||
|
||||
return {
|
||||
canonicalD,
|
||||
event: canonicalEvent,
|
||||
companion: canonicalCompanion,
|
||||
profileEvent,
|
||||
profileTags,
|
||||
profileStorage: migratedStorage,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Blobbi Migration] Migration failed:', error);
|
||||
toast({
|
||||
title: 'Migration failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [user?.pubkey, publishEvent]);
|
||||
|
||||
/**
|
||||
* Ensure a Blobbi is in canonical format before performing an action.
|
||||
*
|
||||
* If the companion is legacy, it will be migrated first.
|
||||
* Returns the canonical companion to use for the action.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if Blobbi is legacy
|
||||
* 2. If legacy: migrate Blobbi
|
||||
* 3. Return the resolved canonical Blobbi
|
||||
*
|
||||
* All interaction handlers should call this before publishing events.
|
||||
*/
|
||||
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<EnsureCanonicalResult | null> => {
|
||||
const { companion, profile } = options;
|
||||
|
||||
// Check if the companion needs migration
|
||||
if (companion.isLegacy) {
|
||||
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
|
||||
|
||||
const migrationResult = await migrateLegacyBlobbi(options);
|
||||
|
||||
if (!migrationResult) {
|
||||
// Migration failed, cannot proceed with action
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the canonical companion AND migrated profile context
|
||||
// CRITICAL: Consumers must use profileAllTags instead of profile.allTags
|
||||
// to avoid restoring stale/legacy values
|
||||
return {
|
||||
wasMigrated: true,
|
||||
companion: migrationResult.companion,
|
||||
allTags: migrationResult.event.tags,
|
||||
content: migrationResult.event.content,
|
||||
profileAllTags: migrationResult.profileTags,
|
||||
profileStorage: migrationResult.profileStorage,
|
||||
};
|
||||
}
|
||||
|
||||
// Companion is already canonical, return profile as-is
|
||||
return {
|
||||
wasMigrated: false,
|
||||
companion,
|
||||
allTags: companion.allTags,
|
||||
content: companion.event.content,
|
||||
profileAllTags: profile.allTags,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [migrateLegacyBlobbi]);
|
||||
|
||||
return {
|
||||
/** Migrate a legacy Blobbi to canonical format */
|
||||
migrateLegacyBlobbi,
|
||||
/** Ensure a Blobbi is canonical before an action, migrating if necessary */
|
||||
ensureCanonicalBlobbiBeforeAction,
|
||||
};
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
} from '@/lib/blobbi';
|
||||
|
||||
/** Maximum number of d-tags per query chunk to avoid relay issues */
|
||||
const CHUNK_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Split an array into chunks of a given size.
|
||||
*/
|
||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
*
|
||||
* Features:
|
||||
* - Fetches ALL pets by d-tag list (no limit: 1)
|
||||
* - Chunks large d-lists into multiple queries for relay compatibility
|
||||
* - Keeps only the newest event per d-tag
|
||||
* - Returns both a lookup record and array of companions
|
||||
* - Provides invalidation and optimistic update helpers
|
||||
*/
|
||||
export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Create a stable query key based on sorted d-tags
|
||||
const sortedDList = useMemo(() => {
|
||||
if (!dList || dList.length === 0) return null;
|
||||
return [...dList].sort();
|
||||
}, [dList]);
|
||||
|
||||
const queryKeyDTags = sortedDList?.join(',') ?? '';
|
||||
|
||||
// Main query to fetch all companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
// Log the dList we're about to query
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
|
||||
// Chunk the d-list for relay compatibility
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
// Query all chunks in parallel
|
||||
const allEvents: NostrEvent[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
|
||||
};
|
||||
|
||||
// Log the filter immediately before query
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
|
||||
|
||||
// Filter to valid events
|
||||
const validEvents = allEvents.filter(isValidBlobbiEvent);
|
||||
|
||||
console.log('[useBlobbisCollection] Valid events:', validEvents.length);
|
||||
|
||||
// Group events by d-tag and keep only the newest per d
|
||||
const eventsByD = new Map<string, NostrEvent>();
|
||||
|
||||
for (const event of validEvents) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (!dTag) continue;
|
||||
|
||||
const existing = eventsByD.get(dTag);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
eventsByD.set(dTag, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse all events into BlobbiCompanion objects
|
||||
const companionsByD: Record<string, BlobbiCompanion> = {};
|
||||
const companions: BlobbiCompanion[] = [];
|
||||
|
||||
for (const [dTag, event] of eventsByD) {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (parsed) {
|
||||
companionsByD[dTag] = parsed;
|
||||
companions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Parsed companions:', {
|
||||
count: companions.length,
|
||||
dTags: Object.keys(companionsByD),
|
||||
});
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
|
||||
// Helper to invalidate and refetch after publishing
|
||||
const invalidate = useCallback(() => {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
});
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update)
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
(prev) => {
|
||||
if (!prev) {
|
||||
return {
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
};
|
||||
}
|
||||
|
||||
// Update the specific companion in the record
|
||||
const newCompanionsByD = {
|
||||
...prev.companionsByD,
|
||||
[parsed.d]: parsed,
|
||||
};
|
||||
|
||||
// Rebuild companions array from the record
|
||||
const newCompanions = Object.values(newCompanionsByD);
|
||||
|
||||
return {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: newCompanions,
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Memoize return values for stability
|
||||
const companionsByD = query.data?.companionsByD ?? {};
|
||||
const companions = query.data?.companions ?? [];
|
||||
|
||||
return {
|
||||
/** Record of companions keyed by d-tag */
|
||||
companionsByD,
|
||||
/** Array of all companions (newest per d-tag) */
|
||||
companions,
|
||||
/** True only when query is loading and no data available */
|
||||
isLoading: query.isLoading,
|
||||
/** True when actively fetching */
|
||||
isFetching: query.isFetching,
|
||||
/** True when data is stale */
|
||||
isStale: query.isStale,
|
||||
/** Query error if any */
|
||||
error: query.error,
|
||||
/** Invalidate and refetch the collection */
|
||||
invalidate,
|
||||
/** Optimistically update a single companion in the cache */
|
||||
updateCompanionEvent,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useNostrPublish } from './useNostrPublish';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
profileNeedsPettingLevelNormalization,
|
||||
profileNeedsOnboardingTagMigration,
|
||||
buildNormalizedProfileTags,
|
||||
isLegacyBlobbonautKind,
|
||||
type BlobbonautProfile,
|
||||
@@ -55,9 +56,10 @@ export function useBlobbonautProfileNormalization({
|
||||
// Check what normalization is needed
|
||||
const needsTagNormalization = profileNeedsPettingLevelNormalization(profile);
|
||||
const needsKindMigration = isLegacyBlobbonautKind(profile.event);
|
||||
const needsOnboardingMigration = profileNeedsOnboardingTagMigration(profile);
|
||||
|
||||
// If no normalization needed, mark as seen and return
|
||||
if (!needsTagNormalization && !needsKindMigration) {
|
||||
if (!needsTagNormalization && !needsKindMigration && !needsOnboardingMigration) {
|
||||
normalizedEventIds.current.add(profile.event.id);
|
||||
return;
|
||||
}
|
||||
@@ -68,6 +70,7 @@ export function useBlobbonautProfileNormalization({
|
||||
const reasons: string[] = [];
|
||||
if (needsTagNormalization) reasons.push('missing pettingLevel');
|
||||
if (needsKindMigration) reasons.push('legacy kind 31125 → 11125');
|
||||
if (needsOnboardingMigration) reasons.push('onboarding_done → blobbi_onboarding_done');
|
||||
|
||||
console.log(`[ProfileNormalization] Profile needs normalization: ${reasons.join(', ')}`);
|
||||
|
||||
|
||||
@@ -17,6 +17,28 @@ import { EncryptedSettingsSchema } from '@/lib/schemas';
|
||||
*/
|
||||
let lastWriteTs: number = 0;
|
||||
|
||||
/**
|
||||
* Persist the lastSync timestamp from encrypted settings into localStorage
|
||||
* so that InitialSyncGate can decide whether to show a spinner on reload.
|
||||
* If a local timestamp exists, localStorage is trustworthy and the app can
|
||||
* render immediately while NostrSync fetches updates in the background.
|
||||
*/
|
||||
export function getLocalSettingsSync(pubkey: string): number {
|
||||
try {
|
||||
return Number(localStorage.getItem(`ditto:settings-lastSync:${pubkey}`)) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalSettingsSync(pubkey: string, lastSync: number): void {
|
||||
try {
|
||||
localStorage.setItem(`ditto:settings-lastSync:${pubkey}`, String(lastSync));
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete encrypted app settings stored in NIP-78
|
||||
*/
|
||||
@@ -112,10 +134,10 @@ export function useEncryptedSettings() {
|
||||
return events[0];
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: Infinity,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes — allows window-focus refetch to pick up cross-device changes
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
@@ -147,7 +169,7 @@ export function useEncryptedSettings() {
|
||||
}
|
||||
},
|
||||
enabled: !!query.data && !!user,
|
||||
staleTime: Infinity,
|
||||
staleTime: 0, // Always re-derive when the upstream event changes
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -216,6 +238,10 @@ export function useEncryptedSettings() {
|
||||
queryClient.setQueryData(['parsedSettings', signedEvent.id], updatedSettings);
|
||||
// Cache is now up to date — pending ref no longer needed
|
||||
pendingSettings.current = null;
|
||||
// Persist the sync timestamp so the next page load can skip the spinner
|
||||
if (user && updatedSettings.lastSync) {
|
||||
setLocalSettingsSync(user.pubkey, updatedSettings.lastSync);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
|
||||
|
||||
/** Kinds whose canonical home is the Zapstore relay. */
|
||||
const ZAPSTORE_KINDS = [32267, 30063];
|
||||
const ZAPSTORE_KINDS = [32267, 30063, 3063];
|
||||
|
||||
/**
|
||||
* Extract write relay URLs from a NIP-65 (kind 10002) relay list event.
|
||||
@@ -64,7 +64,7 @@ export function useEvent(eventId: string | undefined, relays?: string[], authorH
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent | null>({
|
||||
queryKey: ['event', eventId ?? ''],
|
||||
queryKey: ['event', eventId ?? '', relays ?? [], authorHint ?? ''],
|
||||
queryFn: async () => {
|
||||
if (!eventId) return null;
|
||||
const filter: NostrFilter[] = [{ ids: [eventId], limit: 1 }];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { EncryptedSettingsSchema } from "@/lib/schemas";
|
||||
import { useAppContext } from "./useAppContext";
|
||||
import { useCurrentUser } from "./useCurrentUser";
|
||||
import type { EncryptedSettings } from "./useEncryptedSettings";
|
||||
import { type EncryptedSettings, setLocalSettingsSync } from "./useEncryptedSettings";
|
||||
import {
|
||||
type MuteListItem,
|
||||
parseMuteTags,
|
||||
@@ -206,6 +206,9 @@ export function useInitialSync() {
|
||||
if (parsed.theme) {
|
||||
updates.theme = parsed.theme;
|
||||
}
|
||||
if (parsed.customTheme) {
|
||||
updates.customTheme = parsed.customTheme;
|
||||
}
|
||||
if (parsed.autoShareTheme !== undefined) {
|
||||
updates.autoShareTheme = parsed.autoShareTheme;
|
||||
}
|
||||
@@ -227,6 +230,15 @@ export function useInitialSync() {
|
||||
if (parsed.homePage) {
|
||||
updates.homePage = parsed.homePage;
|
||||
}
|
||||
if (parsed.corsProxy) {
|
||||
updates.corsProxy = parsed.corsProxy;
|
||||
}
|
||||
if (parsed.faviconUrl) {
|
||||
updates.faviconUrl = parsed.faviconUrl;
|
||||
}
|
||||
if (parsed.linkPreviewUrl) {
|
||||
updates.linkPreviewUrl = parsed.linkPreviewUrl;
|
||||
}
|
||||
|
||||
return updates;
|
||||
});
|
||||
@@ -242,6 +254,11 @@ export function useInitialSync() {
|
||||
parsed,
|
||||
);
|
||||
|
||||
// Persist the sync timestamp so future reloads can skip the spinner
|
||||
if (parsed.lastSync) {
|
||||
setLocalSettingsSync(user.pubkey, parsed.lastSync);
|
||||
}
|
||||
|
||||
foundSettings = true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Detects whether the virtual keyboard is likely open on mobile devices.
|
||||
*
|
||||
* Uses the Visual Viewport API to compare the visible viewport height against
|
||||
* the full layout viewport. When the keyboard slides up, `visualViewport.height`
|
||||
* shrinks while `window.innerHeight` stays the same (or changes minimally).
|
||||
*
|
||||
* A threshold of 0.75 (75%) is used — if the visible area is less than 75% of
|
||||
* the layout viewport, we assume the keyboard is open.
|
||||
*/
|
||||
export function useKeyboardVisible(): boolean {
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
const THRESHOLD = 0.75;
|
||||
|
||||
const check = () => {
|
||||
const ratio = vv.height / window.innerHeight;
|
||||
const visible = ratio < THRESHOLD;
|
||||
setIsKeyboardVisible(visible);
|
||||
};
|
||||
|
||||
vv.addEventListener('resize', check);
|
||||
check();
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener('resize', check);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isKeyboardVisible;
|
||||
}
|
||||
@@ -1,11 +1,40 @@
|
||||
import { useNostr } from "@nostrify/react";
|
||||
import { useMutation, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import { useAppContext } from "./useAppContext";
|
||||
import { useCurrentUser } from "./useCurrentUser";
|
||||
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
|
||||
/**
|
||||
* Builds a NIP-89 "client" tag from the app display name and an optional
|
||||
* `naddr1` identifier for the kind 31990 handler event.
|
||||
*
|
||||
* Tag format (per NIP-89):
|
||||
* ["client", <name>, <31990:pubkey:d-tag>, <relay-hint>]
|
||||
*
|
||||
* The relay hint is taken from the first relay embedded in the naddr (if any).
|
||||
*/
|
||||
function buildClientTag(name: string, clientNaddr: string | undefined): string[] {
|
||||
if (!clientNaddr) {
|
||||
return ["client", name];
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = nip19.decode(clientNaddr);
|
||||
if (decoded.type !== "naddr") {
|
||||
return ["client", name];
|
||||
}
|
||||
const { kind, pubkey, identifier, relays } = decoded.data;
|
||||
const addr = `${kind}:${pubkey}:${identifier}`;
|
||||
const relayHint = relays?.[0];
|
||||
return relayHint ? ["client", name, addr, relayHint] : ["client", name, addr];
|
||||
} catch {
|
||||
return ["client", name];
|
||||
}
|
||||
}
|
||||
|
||||
export function useNostrPublish(): UseMutationResult<NostrEvent> {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
@@ -16,9 +45,10 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
|
||||
if (user) {
|
||||
const tags = t.tags ?? [];
|
||||
|
||||
// Add the client tag if it doesn't exist
|
||||
// Add the NIP-89 client tag if it doesn't exist
|
||||
if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) {
|
||||
tags.push(["client", config.clientName ?? config.appName, ...(config.client ? [config.client] : [])]);
|
||||
const clientTag = buildClientTag(config.clientName ?? config.appName, config.client);
|
||||
tags.push(clientTag);
|
||||
}
|
||||
|
||||
const event = await user.signer.signEvent({
|
||||
|
||||
@@ -419,11 +419,19 @@ export function useNotifications(): NotificationData {
|
||||
// match because useHasUnreadNotifications uses a 4-element key
|
||||
// ['notifications-unread', pubkey, kindsKey, authorsKey] and setQueryData
|
||||
// requires an exact match (which silently misses the real cache entry).
|
||||
//
|
||||
// NOTE: We intentionally do NOT call invalidateQueries here. Invalidation
|
||||
// triggers an immediate refetch whose queryFn closure may still hold the
|
||||
// old notificationsCursor (from a render before the settings cache update
|
||||
// propagates). That stale refetch re-queries the relay with the old
|
||||
// `since` value, finds the same "unread" events, returns `true`, and
|
||||
// overwrites the `false` we just set — causing the dot to reappear.
|
||||
// The 60-second poll (or real-time subscription) will naturally
|
||||
// re-evaluate once the cursor has fully propagated.
|
||||
queryClient.setQueriesData<boolean>(
|
||||
{ queryKey: ['notifications-unread', user.pubkey] },
|
||||
false,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications-unread', user.pubkey] });
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notifications as read:', error);
|
||||
optimisticCursor.current = null;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useEvent } from '@/hooks/useEvent';
|
||||
|
||||
/**
|
||||
* Given a kind 1018 poll vote event, resolves the human-readable option label(s)
|
||||
* by fetching the parent poll and mapping response IDs to option names.
|
||||
*
|
||||
* Returns an empty string for non-1018 events or if the parent poll hasn't loaded yet.
|
||||
*/
|
||||
export function usePollVoteLabel(event: NostrEvent): string {
|
||||
const parentTag = useMemo(
|
||||
() => event.kind === 1018 ? event.tags.find(([n]) => n === 'e') : undefined,
|
||||
[event],
|
||||
);
|
||||
const parentId = parentTag?.[1];
|
||||
const relayHint = parentTag?.[2] || undefined;
|
||||
const authorHint = parentTag?.[4] || (event.kind === 1018 ? event.tags.find(([n]) => n === 'p')?.[1] : undefined) || undefined;
|
||||
|
||||
const { data: parentPoll } = useEvent(parentId, relayHint ? [relayHint] : undefined, authorHint);
|
||||
|
||||
return useMemo(() => {
|
||||
const responseIds = event.kind === 1018
|
||||
? event.tags.filter(([n]) => n === 'response').map(([, id]) => id)
|
||||
: [];
|
||||
if (responseIds.length === 0) return '';
|
||||
if (!parentPoll) return responseIds.join(', ');
|
||||
const optMap = new Map<string, string>();
|
||||
for (const tag of parentPoll.tags) {
|
||||
if (tag[0] === 'option') optMap.set(tag[1], tag[2]);
|
||||
}
|
||||
return responseIds.map((id) => optMap.get(id) ?? id).join(', ');
|
||||
}, [event, parentPoll]);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Hook for projecting Blobbi decay state in the UI.
|
||||
*
|
||||
* This hook provides a local projection of decay without publishing events.
|
||||
* It recalculates every 60 seconds while the component is mounted.
|
||||
*
|
||||
* The projected state is for UI display only. Actual mutations must
|
||||
* recalculate from the persisted state before publishing.
|
||||
*
|
||||
* @see docs/blobbi/decay-system.md
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbiStats } from '@/lib/blobbi';
|
||||
import { applyBlobbiDecay, getVisibleStatsWithValues, type DecayResult } from '@/lib/blobbi-decay';
|
||||
|
||||
/** UI refresh interval in milliseconds (60 seconds) */
|
||||
const UI_REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Projected Blobbi state for UI display.
|
||||
*/
|
||||
export interface ProjectedBlobbiState {
|
||||
/** Stats after applying projected decay */
|
||||
stats: BlobbiStats;
|
||||
/** Visible stats for the current stage with status indicators */
|
||||
visibleStats: Array<{
|
||||
stat: keyof BlobbiStats;
|
||||
value: number;
|
||||
status: 'critical' | 'warning' | 'normal';
|
||||
}>;
|
||||
/** Time elapsed since last decay (seconds) */
|
||||
elapsedSeconds: number;
|
||||
/** Timestamp of the projection calculation */
|
||||
projectedAt: number;
|
||||
/** Whether this is a fresh projection (recalculated this render) */
|
||||
isFresh: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a projected Blobbi state with decay applied.
|
||||
*
|
||||
* Features:
|
||||
* - Immediately calculates projected state on mount/companion change
|
||||
* - Recalculates every 60 seconds while mounted
|
||||
* - Pure calculation - does not publish any events
|
||||
* - Returns both full stats and stage-appropriate visible stats
|
||||
*
|
||||
* @param companion - The persisted Blobbi companion (source of truth)
|
||||
* @returns Projected state with decay applied, or null if no companion
|
||||
*/
|
||||
export function useProjectedBlobbiState(
|
||||
companion: BlobbiCompanion | null
|
||||
): ProjectedBlobbiState | null {
|
||||
// Track when we last recalculated
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
|
||||
// Set up 60-second refresh interval
|
||||
useEffect(() => {
|
||||
if (!companion) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setRefreshTick(t => t + 1);
|
||||
}, UI_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [companion]);
|
||||
|
||||
// Calculate projected state
|
||||
const projectedState = useMemo((): ProjectedBlobbiState | null => {
|
||||
if (!companion) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Apply decay from persisted state
|
||||
const decayResult: DecayResult = applyBlobbiDecay({
|
||||
stage: companion.stage,
|
||||
state: companion.state,
|
||||
stats: companion.stats,
|
||||
lastDecayAt: companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
// Get visible stats for the stage
|
||||
const visibleStats = getVisibleStatsWithValues(companion.stage, decayResult.stats);
|
||||
|
||||
return {
|
||||
stats: decayResult.stats,
|
||||
visibleStats,
|
||||
elapsedSeconds: decayResult.elapsedSeconds,
|
||||
projectedAt: now,
|
||||
isFresh: true,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTick triggers recalculation
|
||||
}, [companion, refreshTick]);
|
||||
|
||||
return projectedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate projected decay for a companion at a specific timestamp.
|
||||
*
|
||||
* This is a utility function for use outside of React components,
|
||||
* such as in mutation handlers before publishing.
|
||||
*
|
||||
* @param companion - The persisted Blobbi companion
|
||||
* @param now - Unix timestamp to calculate decay to (defaults to current time)
|
||||
* @returns Decay result with updated stats
|
||||
*/
|
||||
export function calculateProjectedDecay(
|
||||
companion: BlobbiCompanion,
|
||||
now?: number
|
||||
): DecayResult {
|
||||
return applyBlobbiDecay({
|
||||
stage: companion.stage,
|
||||
state: companion.state,
|
||||
stats: companion.stats,
|
||||
lastDecayAt: companion.lastDecayAt,
|
||||
now: now ?? Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
@@ -652,3 +652,50 @@
|
||||
@apply min-h-[350px];
|
||||
}
|
||||
|
||||
/* Blobbi idle animations — speed/intensity driven by happiness via inline style */
|
||||
@keyframes blobbi-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-sway {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(1.5deg); }
|
||||
75% { transform: rotate(-1.5deg); }
|
||||
}
|
||||
|
||||
/* Hatch ceremony shake animations */
|
||||
@keyframes blobbi-shake-light {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
25% { transform: rotate(-3deg); }
|
||||
75% { transform: rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-shake-medium {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(-6deg); }
|
||||
40% { transform: rotate(5deg); }
|
||||
60% { transform: rotate(-4deg); }
|
||||
80% { transform: rotate(6deg); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-shake-heavy {
|
||||
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||
15% { transform: rotate(-8deg) scale(1.02); }
|
||||
30% { transform: rotate(7deg) scale(0.98); }
|
||||
45% { transform: rotate(-9deg) scale(1.03); }
|
||||
60% { transform: rotate(8deg) scale(0.97); }
|
||||
75% { transform: rotate(-7deg) scale(1.02); }
|
||||
90% { transform: rotate(9deg) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes blobbi-flash {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes blobbi-fade-to-white {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
/**
|
||||
* Blobbi Decay System
|
||||
*
|
||||
* This module implements the continuous proportional decay system for Blobbi stats.
|
||||
*
|
||||
* Key principles:
|
||||
* - Pure, deterministic calculation based on elapsed time
|
||||
* - Floored stat changes before application
|
||||
* - Stats clamped to 0-100 range
|
||||
* - Stage-specific decay rates and health modifiers
|
||||
* - Persisted state is the source of truth
|
||||
*
|
||||
* @see docs/blobbi/decay-system.md for full documentation
|
||||
*/
|
||||
|
||||
import type { BlobbiStage, BlobbiState, BlobbiStats } from './blobbi';
|
||||
import { STAT_MIN, STAT_MAX } from './blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of applying decay to a Blobbi.
|
||||
* Contains updated stats and metadata about the calculation.
|
||||
*/
|
||||
export interface DecayResult {
|
||||
/** Updated stats after decay (clamped to 0-100) */
|
||||
stats: BlobbiStats;
|
||||
/** Elapsed time in seconds that was used for decay calculation */
|
||||
elapsedSeconds: number;
|
||||
/** The timestamp that should be set as the new last_decay_at */
|
||||
newDecayTimestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input parameters for decay calculation.
|
||||
* Uses the persisted Blobbi state as source of truth.
|
||||
*/
|
||||
export interface DecayInput {
|
||||
/** Current life stage */
|
||||
stage: BlobbiStage;
|
||||
/** Current activity state (awake/sleeping) */
|
||||
state: BlobbiState;
|
||||
/** Current stats from persisted state */
|
||||
stats: Partial<BlobbiStats>;
|
||||
/** Unix timestamp of last decay application */
|
||||
lastDecayAt: number | undefined;
|
||||
/** Current unix timestamp (defaults to now) */
|
||||
now?: number;
|
||||
}
|
||||
|
||||
// ─── Constants: Decay Rates ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Baby stage decay rates (per hour).
|
||||
*
|
||||
* Design goal: Needs attention every 3-5 hours.
|
||||
*/
|
||||
const BABY_DECAY = {
|
||||
hunger: -7.0,
|
||||
happiness: -4.0,
|
||||
hygiene: -5.0,
|
||||
energy: {
|
||||
awake: -8.0,
|
||||
sleeping: 6.0, // Regeneration
|
||||
},
|
||||
health: {
|
||||
base: -0.75,
|
||||
hungerBelow70: -0.75,
|
||||
hungerBelow40: -1.25,
|
||||
hygieneBelow70: -0.75,
|
||||
hygieneBelow40: -1.25,
|
||||
energyBelow50: -0.5,
|
||||
energyBelow25: -1.0,
|
||||
happinessBelow50: -0.5,
|
||||
happinessBelow25: -1.0,
|
||||
// Regeneration when all stats are >= 80
|
||||
regenThreshold: 80,
|
||||
regenRate: 1.5,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Adult stage decay rates (per hour).
|
||||
*
|
||||
* Design goal: Needs attention every 5-7 hours.
|
||||
*/
|
||||
const ADULT_DECAY = {
|
||||
hunger: -4.5,
|
||||
happiness: -2.5,
|
||||
hygiene: -3.5,
|
||||
energy: {
|
||||
awake: -5.0,
|
||||
sleeping: 5.0, // Regeneration
|
||||
},
|
||||
health: {
|
||||
base: -0.4,
|
||||
hungerBelow60: -0.5,
|
||||
hungerBelow30: -1.0,
|
||||
hygieneBelow60: -0.5,
|
||||
hygieneBelow30: -1.0,
|
||||
energyBelow40: -0.4,
|
||||
energyBelow20: -0.8,
|
||||
happinessBelow40: -0.4,
|
||||
happinessBelow20: -0.8,
|
||||
// Regeneration when all stats are >= 80
|
||||
regenThreshold: 80,
|
||||
regenRate: 1.0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── Constants: Warning Thresholds ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Warning thresholds by stage.
|
||||
* Warning = stat below this value indicates the Blobbi needs attention.
|
||||
*/
|
||||
export const WARNING_THRESHOLDS = {
|
||||
egg: {
|
||||
hygiene: 75,
|
||||
health: 75,
|
||||
happiness: 75,
|
||||
},
|
||||
baby: {
|
||||
hunger: 65,
|
||||
happiness: 65,
|
||||
hygiene: 65,
|
||||
energy: 65,
|
||||
health: 65,
|
||||
},
|
||||
adult: {
|
||||
hunger: 60,
|
||||
happiness: 60,
|
||||
hygiene: 60,
|
||||
energy: 60,
|
||||
health: 60,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Critical thresholds by stage.
|
||||
* Critical = stat below this value indicates urgent attention needed.
|
||||
*/
|
||||
export const CRITICAL_THRESHOLDS = {
|
||||
egg: {
|
||||
hygiene: 45,
|
||||
health: 45,
|
||||
happiness: 45,
|
||||
},
|
||||
baby: {
|
||||
hunger: 35,
|
||||
happiness: 35,
|
||||
hygiene: 35,
|
||||
energy: 25,
|
||||
health: 35,
|
||||
},
|
||||
adult: {
|
||||
hunger: 30,
|
||||
happiness: 30,
|
||||
hygiene: 30,
|
||||
energy: 20,
|
||||
health: 30,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clamp a value to the STAT_MIN-STAT_MAX range (1-100).
|
||||
* Stats can never reach true zero - minimum is always 1.
|
||||
*/
|
||||
function clamp(value: number): number {
|
||||
return Math.max(STAT_MIN, Math.min(STAT_MAX, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stat value with fallback to 100 (full).
|
||||
*/
|
||||
function getStat(stats: Partial<BlobbiStats>, key: keyof BlobbiStats): number {
|
||||
return stats[key] ?? 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hours to the elapsed time unit for calculation.
|
||||
* @param hours - Elapsed hours
|
||||
* @returns Rate multiplier for the elapsed time
|
||||
*/
|
||||
function hoursFromSeconds(seconds: number): number {
|
||||
return seconds / 3600;
|
||||
}
|
||||
|
||||
// ─── Stage-Specific Decay Calculators ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate egg stage decay.
|
||||
*
|
||||
* Eggs only decay hygiene, health, and happiness.
|
||||
* Hunger and energy are fixed at 100.
|
||||
*/
|
||||
function calculateEggDecay(
|
||||
stats: Partial<BlobbiStats>,
|
||||
_elapsedHours: number
|
||||
): BlobbiStats {
|
||||
// Eggs do not decay — all stats remain fixed until hatching.
|
||||
return {
|
||||
hunger: 100,
|
||||
energy: 100,
|
||||
hygiene: getStat(stats, 'hygiene'),
|
||||
health: getStat(stats, 'health'),
|
||||
happiness: getStat(stats, 'happiness'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate baby stage decay.
|
||||
*/
|
||||
function calculateBabyDecay(
|
||||
stats: Partial<BlobbiStats>,
|
||||
state: BlobbiState,
|
||||
elapsedHours: number
|
||||
): BlobbiStats {
|
||||
const isSleeping = state === 'sleeping';
|
||||
|
||||
// Get current values
|
||||
let hunger = getStat(stats, 'hunger');
|
||||
let happiness = getStat(stats, 'happiness');
|
||||
let hygiene = getStat(stats, 'hygiene');
|
||||
let energy = getStat(stats, 'energy');
|
||||
let health = getStat(stats, 'health');
|
||||
|
||||
// Calculate basic stat decay/regen
|
||||
const hungerDelta = BABY_DECAY.hunger * elapsedHours;
|
||||
const happinessDelta = BABY_DECAY.happiness * elapsedHours;
|
||||
const hygieneDelta = BABY_DECAY.hygiene * elapsedHours;
|
||||
const energyDelta = (isSleeping ? BABY_DECAY.energy.sleeping : BABY_DECAY.energy.awake) * elapsedHours;
|
||||
|
||||
// Apply basic deltas
|
||||
hunger = clamp(hunger + Math.floor(hungerDelta));
|
||||
happiness = clamp(happiness + Math.floor(happinessDelta));
|
||||
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
|
||||
energy = clamp(energy + Math.floor(energyDelta));
|
||||
|
||||
// Calculate health (complex conditional decay + possible regen)
|
||||
let healthDelta = BABY_DECAY.health.base * elapsedHours;
|
||||
|
||||
// Hunger penalties
|
||||
if (hunger < 70) healthDelta += BABY_DECAY.health.hungerBelow70 * elapsedHours;
|
||||
if (hunger < 40) healthDelta += BABY_DECAY.health.hungerBelow40 * elapsedHours;
|
||||
|
||||
// Hygiene penalties
|
||||
if (hygiene < 70) healthDelta += BABY_DECAY.health.hygieneBelow70 * elapsedHours;
|
||||
if (hygiene < 40) healthDelta += BABY_DECAY.health.hygieneBelow40 * elapsedHours;
|
||||
|
||||
// Energy penalties
|
||||
if (energy < 50) healthDelta += BABY_DECAY.health.energyBelow50 * elapsedHours;
|
||||
if (energy < 25) healthDelta += BABY_DECAY.health.energyBelow25 * elapsedHours;
|
||||
|
||||
// Happiness penalties
|
||||
if (happiness < 50) healthDelta += BABY_DECAY.health.happinessBelow50 * elapsedHours;
|
||||
if (happiness < 25) healthDelta += BABY_DECAY.health.happinessBelow25 * elapsedHours;
|
||||
|
||||
// Health regeneration (all stats >= 80)
|
||||
const threshold = BABY_DECAY.health.regenThreshold;
|
||||
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
|
||||
healthDelta += BABY_DECAY.health.regenRate * elapsedHours;
|
||||
}
|
||||
|
||||
health = clamp(health + Math.floor(healthDelta));
|
||||
|
||||
return { hunger, happiness, hygiene, energy, health };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate adult stage decay.
|
||||
*/
|
||||
function calculateAdultDecay(
|
||||
stats: Partial<BlobbiStats>,
|
||||
state: BlobbiState,
|
||||
elapsedHours: number
|
||||
): BlobbiStats {
|
||||
const isSleeping = state === 'sleeping';
|
||||
|
||||
// Get current values
|
||||
let hunger = getStat(stats, 'hunger');
|
||||
let happiness = getStat(stats, 'happiness');
|
||||
let hygiene = getStat(stats, 'hygiene');
|
||||
let energy = getStat(stats, 'energy');
|
||||
let health = getStat(stats, 'health');
|
||||
|
||||
// Calculate basic stat decay/regen
|
||||
const hungerDelta = ADULT_DECAY.hunger * elapsedHours;
|
||||
const happinessDelta = ADULT_DECAY.happiness * elapsedHours;
|
||||
const hygieneDelta = ADULT_DECAY.hygiene * elapsedHours;
|
||||
const energyDelta = (isSleeping ? ADULT_DECAY.energy.sleeping : ADULT_DECAY.energy.awake) * elapsedHours;
|
||||
|
||||
// Apply basic deltas
|
||||
hunger = clamp(hunger + Math.floor(hungerDelta));
|
||||
happiness = clamp(happiness + Math.floor(happinessDelta));
|
||||
hygiene = clamp(hygiene + Math.floor(hygieneDelta));
|
||||
energy = clamp(energy + Math.floor(energyDelta));
|
||||
|
||||
// Calculate health (complex conditional decay + possible regen)
|
||||
let healthDelta = ADULT_DECAY.health.base * elapsedHours;
|
||||
|
||||
// Hunger penalties
|
||||
if (hunger < 60) healthDelta += ADULT_DECAY.health.hungerBelow60 * elapsedHours;
|
||||
if (hunger < 30) healthDelta += ADULT_DECAY.health.hungerBelow30 * elapsedHours;
|
||||
|
||||
// Hygiene penalties
|
||||
if (hygiene < 60) healthDelta += ADULT_DECAY.health.hygieneBelow60 * elapsedHours;
|
||||
if (hygiene < 30) healthDelta += ADULT_DECAY.health.hygieneBelow30 * elapsedHours;
|
||||
|
||||
// Energy penalties
|
||||
if (energy < 40) healthDelta += ADULT_DECAY.health.energyBelow40 * elapsedHours;
|
||||
if (energy < 20) healthDelta += ADULT_DECAY.health.energyBelow20 * elapsedHours;
|
||||
|
||||
// Happiness penalties
|
||||
if (happiness < 40) healthDelta += ADULT_DECAY.health.happinessBelow40 * elapsedHours;
|
||||
if (happiness < 20) healthDelta += ADULT_DECAY.health.happinessBelow20 * elapsedHours;
|
||||
|
||||
// Health regeneration (all stats >= 80)
|
||||
const threshold = ADULT_DECAY.health.regenThreshold;
|
||||
if (hunger >= threshold && happiness >= threshold && hygiene >= threshold && energy >= threshold) {
|
||||
healthDelta += ADULT_DECAY.health.regenRate * elapsedHours;
|
||||
}
|
||||
|
||||
health = clamp(health + Math.floor(healthDelta));
|
||||
|
||||
return { hunger, happiness, hygiene, energy, health };
|
||||
}
|
||||
|
||||
// ─── Main Decay Function ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply decay to a Blobbi based on elapsed time since last decay.
|
||||
*
|
||||
* This is a pure, deterministic function that:
|
||||
* 1. Calculates elapsed time from lastDecayAt to now
|
||||
* 2. Applies stage-specific decay rates
|
||||
* 3. Floors all stat deltas before application
|
||||
* 4. Clamps final stats to 0-100 range
|
||||
* 5. Returns updated stats without side effects
|
||||
*
|
||||
* @param input - Decay input parameters from persisted state
|
||||
* @returns DecayResult with updated stats and new decay timestamp
|
||||
*/
|
||||
export function applyBlobbiDecay(input: DecayInput): DecayResult {
|
||||
const now = input.now ?? Math.floor(Date.now() / 1000);
|
||||
const lastDecayAt = input.lastDecayAt ?? now;
|
||||
|
||||
// Calculate elapsed time
|
||||
const elapsedSeconds = Math.max(0, now - lastDecayAt);
|
||||
const elapsedHours = hoursFromSeconds(elapsedSeconds);
|
||||
|
||||
// If no time has passed, return current stats unchanged
|
||||
if (elapsedSeconds === 0) {
|
||||
return {
|
||||
stats: {
|
||||
hunger: getStat(input.stats, 'hunger'),
|
||||
happiness: getStat(input.stats, 'happiness'),
|
||||
health: getStat(input.stats, 'health'),
|
||||
hygiene: getStat(input.stats, 'hygiene'),
|
||||
energy: getStat(input.stats, 'energy'),
|
||||
},
|
||||
elapsedSeconds: 0,
|
||||
newDecayTimestamp: now,
|
||||
};
|
||||
}
|
||||
|
||||
// Apply stage-specific decay
|
||||
let newStats: BlobbiStats;
|
||||
switch (input.stage) {
|
||||
case 'egg':
|
||||
newStats = calculateEggDecay(input.stats, elapsedHours);
|
||||
break;
|
||||
case 'baby':
|
||||
newStats = calculateBabyDecay(input.stats, input.state, elapsedHours);
|
||||
break;
|
||||
case 'adult':
|
||||
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
|
||||
break;
|
||||
default:
|
||||
// Fallback to adult decay for unknown stages
|
||||
newStats = calculateAdultDecay(input.stats, input.state, elapsedHours);
|
||||
}
|
||||
|
||||
return {
|
||||
stats: newStats,
|
||||
elapsedSeconds,
|
||||
newDecayTimestamp: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Threshold Checkers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a stat is at warning level for the given stage.
|
||||
*/
|
||||
export function isStatAtWarning(
|
||||
stage: BlobbiStage,
|
||||
stat: keyof BlobbiStats,
|
||||
value: number
|
||||
): boolean {
|
||||
const thresholds = WARNING_THRESHOLDS[stage];
|
||||
const threshold = (thresholds as Record<string, number>)[stat];
|
||||
if (threshold === undefined) return false;
|
||||
return value < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stat is at critical level for the given stage.
|
||||
*/
|
||||
export function isStatAtCritical(
|
||||
stage: BlobbiStage,
|
||||
stat: keyof BlobbiStats,
|
||||
value: number
|
||||
): boolean {
|
||||
const thresholds = CRITICAL_THRESHOLDS[stage];
|
||||
const threshold = (thresholds as Record<string, number>)[stat];
|
||||
if (threshold === undefined) return false;
|
||||
return value < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status level for a stat.
|
||||
* @returns 'critical' | 'warning' | 'normal'
|
||||
*/
|
||||
export function getStatStatus(
|
||||
stage: BlobbiStage,
|
||||
stat: keyof BlobbiStats,
|
||||
value: number
|
||||
): 'critical' | 'warning' | 'normal' {
|
||||
if (isStatAtCritical(stage, stat, value)) return 'critical';
|
||||
if (isStatAtWarning(stage, stat, value)) return 'warning';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats that are at warning or critical level.
|
||||
*/
|
||||
export function getStatsNeedingAttention(
|
||||
stage: BlobbiStage,
|
||||
stats: Partial<BlobbiStats>
|
||||
): Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> {
|
||||
const results: Array<{ stat: keyof BlobbiStats; value: number; status: 'warning' | 'critical' }> = [];
|
||||
|
||||
const statKeys: (keyof BlobbiStats)[] = ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
|
||||
|
||||
// For eggs, only check relevant stats
|
||||
const relevantStats = stage === 'egg'
|
||||
? ['health', 'hygiene', 'happiness'] as (keyof BlobbiStats)[]
|
||||
: statKeys;
|
||||
|
||||
for (const stat of relevantStats) {
|
||||
const value = stats[stat] ?? 100;
|
||||
const status = getStatStatus(stage, stat, value);
|
||||
if (status !== 'normal') {
|
||||
results.push({ stat, value, status });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Visible Stats Helper ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Visibility threshold: stats at or above this value are hidden in the UI.
|
||||
* Only stats below this threshold are displayed.
|
||||
*/
|
||||
export const STAT_VISIBILITY_THRESHOLD = 70;
|
||||
|
||||
/**
|
||||
* Get the stats that should be visible for a given stage.
|
||||
* Eggs only show health, hygiene, happiness.
|
||||
* Baby/adult show all stats.
|
||||
*/
|
||||
export function getVisibleStats(stage: BlobbiStage): (keyof BlobbiStats)[] {
|
||||
if (stage === 'egg') {
|
||||
return ['health', 'hygiene', 'happiness'];
|
||||
}
|
||||
return ['hunger', 'happiness', 'health', 'hygiene', 'energy'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible stats with their values for display.
|
||||
* Stats at or above STAT_VISIBILITY_THRESHOLD are filtered out.
|
||||
*/
|
||||
export function getVisibleStatsWithValues(
|
||||
stage: BlobbiStage,
|
||||
stats: Partial<BlobbiStats>
|
||||
): Array<{ stat: keyof BlobbiStats; value: number; status: 'critical' | 'warning' | 'normal' }> {
|
||||
const visibleStats = getVisibleStats(stage);
|
||||
return visibleStats
|
||||
.map(stat => ({
|
||||
stat,
|
||||
value: stats[stat] ?? 100,
|
||||
status: getStatStatus(stage, stat, stats[stat] ?? 100),
|
||||
}))
|
||||
.filter(entry => entry.value < STAT_VISIBILITY_THRESHOLD);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Blobbi → EggGraphic Adapter
|
||||
*
|
||||
* This module provides a translation layer between the Blobbi domain model
|
||||
* and the portable EggGraphic visual module.
|
||||
*
|
||||
* PURPOSE:
|
||||
* - Keep the game/domain visual model decoupled from EggGraphic internals
|
||||
* - Provide explicit mappings between vocabularies
|
||||
* - Act as the single translation boundary for visual rendering
|
||||
*
|
||||
* USAGE:
|
||||
* ```ts
|
||||
* const eggVisual = toEggGraphicVisualBlobbi(companion);
|
||||
* // Pass eggVisual to EggGraphic component
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { EggVisualBlobbi } from '@/blobbi/egg';
|
||||
import {
|
||||
type BlobbiCompanion,
|
||||
type BlobbiPattern,
|
||||
type BlobbiSpecialMark,
|
||||
type BlobbiStage,
|
||||
getTagValue,
|
||||
} from './blobbi';
|
||||
|
||||
// ─── Egg Module Types (derived from EggVisualBlobbi) ──────────────────────────
|
||||
|
||||
/** Life stage values accepted by EggGraphic */
|
||||
type EggLifeStage = NonNullable<EggVisualBlobbi['lifeStage']>;
|
||||
|
||||
/** Pattern values accepted by EggGraphic */
|
||||
type EggPattern = NonNullable<EggVisualBlobbi['pattern']>;
|
||||
|
||||
/** Special mark values accepted by EggGraphic */
|
||||
type EggSpecialMark = NonNullable<EggVisualBlobbi['specialMark']>;
|
||||
|
||||
/** Theme variant values accepted by EggGraphic */
|
||||
type EggThemeVariant = NonNullable<EggVisualBlobbi['themeVariant']>;
|
||||
|
||||
// ─── Mapping Tables ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps Blobbi pattern values to EggGraphic pattern values.
|
||||
* Explicit mapping allows vocabularies to diverge in the future.
|
||||
*/
|
||||
const PATTERN_MAP: Record<BlobbiPattern, EggPattern> = {
|
||||
'solid': 'solid',
|
||||
'spotted': 'spotted',
|
||||
'striped': 'striped',
|
||||
'gradient': 'gradient',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Blobbi special mark values to EggGraphic special mark values.
|
||||
*/
|
||||
const SPECIAL_MARK_MAP: Record<BlobbiSpecialMark, EggSpecialMark> = {
|
||||
'none': 'none',
|
||||
'star': 'star',
|
||||
'heart': 'heart',
|
||||
'sparkle': 'sparkle',
|
||||
'blush': 'blush',
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps Blobbi stage values to EggGraphic life stage values.
|
||||
*/
|
||||
const LIFE_STAGE_MAP: Record<BlobbiStage, EggLifeStage> = {
|
||||
'egg': 'egg',
|
||||
'baby': 'baby',
|
||||
'adult': 'adult',
|
||||
};
|
||||
|
||||
// ─── Fallback Values ──────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PATTERN: EggPattern = 'solid';
|
||||
const DEFAULT_SPECIAL_MARK: EggSpecialMark = 'none';
|
||||
const DEFAULT_LIFE_STAGE: EggLifeStage = 'egg';
|
||||
const DEFAULT_THEME_VARIANT: EggThemeVariant = 'default';
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract crossover app identifier from companion tags.
|
||||
*/
|
||||
function extractCrossoverApp(allTags: string[][]): string | undefined {
|
||||
return getTagValue(allTags, 'crossover_app');
|
||||
}
|
||||
|
||||
// ─── Main Adapter Function ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a BlobbiCompanion to EggVisualBlobbi for rendering.
|
||||
*
|
||||
* This is the TRANSLATION BOUNDARY between the Blobbi domain model
|
||||
* and the EggGraphic visual module.
|
||||
*
|
||||
* The adapter:
|
||||
* - Maps vocabulary values through explicit mapping tables
|
||||
* - Passes through full tags for EggGraphic metadata lookups
|
||||
* - Provides safe fallbacks for any missing/invalid data
|
||||
* - Does NOT leak app-specific assumptions into EggGraphic
|
||||
*
|
||||
* @param companion - The parsed BlobbiCompanion from parseBlobbiEvent
|
||||
* @param themeVariant - Optional theme variant override
|
||||
* @returns Visual data compatible with EggVisualBlobbi
|
||||
*/
|
||||
export function toEggGraphicVisualBlobbi(
|
||||
companion: BlobbiCompanion,
|
||||
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
|
||||
): EggVisualBlobbi {
|
||||
const { visualTraits, stage, allTags } = companion;
|
||||
|
||||
return {
|
||||
// Colors pass through directly (already CSS hex values)
|
||||
baseColor: visualTraits.baseColor,
|
||||
secondaryColor: visualTraits.secondaryColor,
|
||||
|
||||
// Mapped through explicit tables with fallbacks
|
||||
pattern: PATTERN_MAP[visualTraits.pattern] ?? DEFAULT_PATTERN,
|
||||
specialMark: SPECIAL_MARK_MAP[visualTraits.specialMark] ?? DEFAULT_SPECIAL_MARK,
|
||||
lifeStage: LIFE_STAGE_MAP[stage] ?? DEFAULT_LIFE_STAGE,
|
||||
|
||||
// Theme variant
|
||||
themeVariant,
|
||||
|
||||
// Pass through full tags for EggGraphic metadata lookups
|
||||
tags: allTags,
|
||||
|
||||
// Extracted convenience values
|
||||
crossoverApp: extractCrossoverApp(allTags),
|
||||
|
||||
// NOTE: We intentionally do NOT pass companion.name as title here.
|
||||
// The EggGraphic 'title' field is for special designations (e.g., "Divine"),
|
||||
// not the pet's name. The pet name is displayed separately by the parent component.
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two EggVisualBlobbi configurations are visually equivalent.
|
||||
* Useful for memoization and avoiding unnecessary re-renders.
|
||||
*/
|
||||
export function areEggGraphicVisualsEqual(
|
||||
a: EggVisualBlobbi,
|
||||
b: EggVisualBlobbi
|
||||
): boolean {
|
||||
return (
|
||||
a.baseColor === b.baseColor &&
|
||||
a.secondaryColor === b.secondaryColor &&
|
||||
a.pattern === b.pattern &&
|
||||
a.specialMark === b.specialMark &&
|
||||
a.lifeStage === b.lifeStage &&
|
||||
a.themeVariant === b.themeVariant
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-1568
File diff suppressed because it is too large
Load Diff
@@ -478,7 +478,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
id: 'development',
|
||||
showKey: 'showDevelopment',
|
||||
feedKey: 'feedIncludeDevelopment',
|
||||
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 31990],
|
||||
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 30063, 31990],
|
||||
label: 'Development',
|
||||
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
|
||||
route: 'development',
|
||||
@@ -546,9 +546,10 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
|
||||
35128: 'nsite',
|
||||
30008: 'profile badges',
|
||||
30817: 'repository issue',
|
||||
32267: 'app',
|
||||
32267: 'Zapstore app',
|
||||
31990: 'app',
|
||||
30063: 'release',
|
||||
30063: 'Zapstore release',
|
||||
3063: 'Zapstore asset',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+42
-4
@@ -14,15 +14,53 @@ export function isReplyEvent(event: NostrEvent): boolean {
|
||||
return nonMentionTags.length > 0;
|
||||
}
|
||||
|
||||
/** Hints extracted from an `e` tag for relay resolution. */
|
||||
export interface ParentEventHints {
|
||||
id: string;
|
||||
relayHint?: string;
|
||||
authorHint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the parent (replied-to) event ID from an event's tags following NIP-10 conventions.
|
||||
* Supports both the preferred marked-tag scheme and the deprecated positional scheme.
|
||||
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
|
||||
*/
|
||||
export function getParentEventId(event: NostrEvent): string | undefined {
|
||||
return getParentEventTag(event)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the parent event ID along with relay and author hints from the `e` tag.
|
||||
* Returns the full NIP-10 hints (relay URL at position [2], author pubkey at position [4]).
|
||||
*
|
||||
* When the `e` tag doesn't include a pubkey at position [4] (many clients omit it),
|
||||
* falls back to the first `p` tag in the event, which per NIP-10 convention contains
|
||||
* the pubkey of the author being replied to.
|
||||
*/
|
||||
export function getParentEventHints(event: NostrEvent): ParentEventHints | undefined {
|
||||
const tag = getParentEventTag(event);
|
||||
if (!tag) return undefined;
|
||||
|
||||
// Prefer the pubkey embedded in the e tag (NIP-10 position [4]).
|
||||
// Fall back to the first p tag, which conventionally holds the parent author's pubkey.
|
||||
const authorHint = tag[4] || event.tags.find(([name]) => name === 'p')?.[1] || undefined;
|
||||
|
||||
return {
|
||||
id: tag[1],
|
||||
relayHint: tag[2] || undefined,
|
||||
authorHint,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw parent `e` tag from an event following NIP-10 conventions.
|
||||
* For kind 7 reactions, uses NIP-25 semantics: the last `e` tag is the reacted-to event.
|
||||
*/
|
||||
function getParentEventTag(event: NostrEvent): string[] | undefined {
|
||||
// NIP-25: for kind 7 reactions, the target event is always the last e-tag
|
||||
if (event.kind === 7) {
|
||||
return event.tags.findLast(([name]) => name === 'e')?.[1];
|
||||
return event.tags.findLast(([name]) => name === 'e');
|
||||
}
|
||||
|
||||
// Exclude "mention" e-tags — they are inline quotes, not reply/root references
|
||||
@@ -31,12 +69,12 @@ export function getParentEventId(event: NostrEvent): string | undefined {
|
||||
|
||||
// Preferred: look for marked "reply" tag first
|
||||
const replyTag = eTags.find(([, , , marker]) => marker === 'reply');
|
||||
if (replyTag) return replyTag[1];
|
||||
if (replyTag) return replyTag;
|
||||
|
||||
// If there's a "root" marker but no "reply" marker, the event replies directly to root
|
||||
const rootTag = eTags.find(([, , , marker]) => marker === 'root');
|
||||
if (rootTag) return rootTag[1];
|
||||
if (rootTag) return rootTag;
|
||||
|
||||
// Deprecated positional scheme: last non-mention e-tag is the reply target
|
||||
return eTags[eTags.length - 1][1];
|
||||
return eTags[eTags.length - 1];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user