Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 213bbb21c1 | |||
| dd3ae4da4e | |||
| 681d2ab90b | |||
| 24a645277e | |||
| fa34922cce | |||
| 89c71ed073 | |||
| 0f02563d3a | |||
| f49909dedf | |||
| ab43225f0c | |||
| 2bb1b07dd6 | |||
| f93c759bf2 | |||
| 38630be23d | |||
| ef4ac2e3f4 | |||
| 32b36b2f54 | |||
| dee5c82fa8 | |||
| 22d66a28d7 | |||
| 984a56c412 | |||
| 207e7a13a2 | |||
| cc7feebbb0 | |||
| 9b8cff63da | |||
| 925619b13c | |||
| ceb7bbc718 | |||
| 53a607fa53 | |||
| e13473809d | |||
| e9eeebc4b1 | |||
| b42d241882 | |||
| 68da609a9e | |||
| 1afa78ae39 | |||
| 00a9ad20de | |||
| e0ff462f12 | |||
| f4e38123e4 | |||
| eb1c873b9a | |||
| 22f13c1505 | |||
| cbfc8f149f | |||
| 2e41859747 | |||
| d28364531b | |||
| f3eb4adba5 | |||
| 0487586af9 | |||
| 2c737ca322 | |||
| c9823055fd | |||
| d2cd5f22bf | |||
| 2d1a3ff6f5 | |||
| 90bd10d87a | |||
| 280bcbd5ab | |||
| 65ecfca05e | |||
| 91f5afc110 | |||
| 1c980fb039 | |||
| e93c665123 | |||
| a80b306248 | |||
| c8c294a8ad |
@@ -108,6 +108,7 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
#### When to Check for Collisions
|
||||
|
||||
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
|
||||
|
||||
#### Implementation Pattern
|
||||
|
||||
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
|
||||
|
||||
```typescript
|
||||
// Before publishing a new addressable event:
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'Change the slug or edit the existing item.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to publish
|
||||
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
|
||||
```
|
||||
|
||||
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
|
||||
|
||||
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
|
||||
|
||||
### Nostr Login
|
||||
|
||||
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
|
||||
|
||||
+85
-6
@@ -1,5 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
@@ -36,7 +115,7 @@
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
@@ -91,11 +170,11 @@
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring kind 10008 profile badge lists
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
@@ -114,7 +193,7 @@
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
@@ -141,10 +220,10 @@
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.2.9"
|
||||
versionName "2.5.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -60,7 +60,7 @@ const builtinThemes = {
|
||||
};
|
||||
```
|
||||
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
|
||||
### ThemeConfig
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.9;
|
||||
MARKETING_VERSION = 2.5.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.2.9;
|
||||
MARKETING_VERSION = 2.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+1887
-54
File diff suppressed because it is too large
Load Diff
+15
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.2.9",
|
||||
"version": "2.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -53,8 +53,19 @@
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@nostrify/nostrify": "^0.51.0",
|
||||
"@nostrify/react": "^0.4.0",
|
||||
"@milkdown/core": "^7.20.0",
|
||||
"@milkdown/ctx": "^7.20.0",
|
||||
"@milkdown/plugin-clipboard": "^7.20.0",
|
||||
"@milkdown/plugin-history": "^7.20.0",
|
||||
"@milkdown/plugin-listener": "^7.20.0",
|
||||
"@milkdown/plugin-upload": "^7.20.0",
|
||||
"@milkdown/preset-commonmark": "^7.20.0",
|
||||
"@milkdown/preset-gfm": "^7.20.0",
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.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",
|
||||
@@ -117,6 +128,7 @@
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.12.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"slugify": "^1.6.8",
|
||||
"smol-toml": "^1.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
+85
-6
@@ -1,5 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
@@ -36,7 +115,7 @@
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
@@ -91,11 +170,11 @@
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring kind 10008 profile badge lists
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
@@ -114,7 +193,7 @@
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
@@ -141,10 +220,10 @@
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
|
||||
+22
-5
@@ -16,12 +16,14 @@ import NostrProvider from "@/components/NostrProvider";
|
||||
import { NostrSync } from "@/components/NostrSync";
|
||||
import { PlausibleProvider } from "@/components/PlausibleProvider";
|
||||
import { SentryProvider } from "@/components/SentryProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -148,15 +150,30 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse and validate build-time ditto.json overrides from the env string.
|
||||
* Returns an empty object when no config file was provided or validation fails.
|
||||
*/
|
||||
function parseDittoConfig(): DittoConfig {
|
||||
try {
|
||||
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
|
||||
if (!json) return {};
|
||||
return DittoConfigSchema.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge hardcoded defaults with build-time ditto.json overrides.
|
||||
* Deep-merges feedSettings so a partial override doesn't erase defaults.
|
||||
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
|
||||
*/
|
||||
const dittoConfig = parseDittoConfig();
|
||||
const defaultConfig: AppConfig = {
|
||||
...hardcodedConfig,
|
||||
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
|
||||
? __DITTO_CONFIG__
|
||||
: {}),
|
||||
...dittoConfig,
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
|
||||
};
|
||||
|
||||
export function App() {
|
||||
@@ -184,11 +201,11 @@ export function App() {
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { getExtraKindDef } from "./lib/extraKinds";
|
||||
@@ -32,6 +34,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
@@ -136,6 +139,8 @@ export function AppRouter() {
|
||||
return (
|
||||
<AudioPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<MinimizedAudioBar />
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
@@ -207,6 +212,8 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route path="/webxdc" element={<WebxdcFeedPage />} />
|
||||
<Route path="/articles/new" element={<ArticleEditorPage />} />
|
||||
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
|
||||
<Route
|
||||
path="/articles"
|
||||
element={
|
||||
@@ -214,6 +221,7 @@ export function AppRouter() {
|
||||
kind={articlesDef.kind}
|
||||
title={articlesDef.label}
|
||||
icon={sidebarItemIcon("articles", "size-5")}
|
||||
fabHref="/articles/new"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,7 @@ export {
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
buildHatchPhrase,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
@@ -70,7 +71,7 @@ export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_WALL_EDIT,
|
||||
KIND_PROFILE_TABS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
@@ -52,18 +53,14 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<BlobbiCompanion | null> => {
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag],
|
||||
}]);
|
||||
const event = await fetchFreshEvent(
|
||||
nostr,
|
||||
{ kinds: [KIND_BLOBBI_STATE], authors: [pubkey], '#d': [dTag] },
|
||||
{ eoseTimeout: 1000 },
|
||||
);
|
||||
|
||||
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;
|
||||
if (!event || !isValidBlobbiEvent(event)) return null;
|
||||
return parseBlobbiEvent(event) ?? null;
|
||||
}, [nostr]);
|
||||
|
||||
/** Optimistically update the TanStack cache so the companion reacts immediately. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { fetchFreshEvents } from '@/lib/fetchFreshEvent';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
@@ -51,46 +52,34 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
// Main query to fetch all companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryFn: async ({ signal }) => {
|
||||
queryFn: async () => {
|
||||
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
|
||||
// Fetch all chunks, using a relaxed eoseTimeout (1000ms) so slower
|
||||
// relays have time to respond and we get the freshest events.
|
||||
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 });
|
||||
const events = await fetchFreshEvents(
|
||||
nostr,
|
||||
[{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
}],
|
||||
{ eoseTimeout: 1000 },
|
||||
);
|
||||
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>();
|
||||
|
||||
@@ -116,11 +105,6 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Parsed companions:', {
|
||||
count: companions.length,
|
||||
dTags: Object.keys(companionsByD),
|
||||
});
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
|
||||
@@ -892,13 +892,15 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
|
||||
const isLegacy = isLegacyBlobbiEvent(event);
|
||||
|
||||
// Concise, structured debug log
|
||||
console.log('[Blobbi]', {
|
||||
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
|
||||
name,
|
||||
isLegacy,
|
||||
hasSeed: !!seed,
|
||||
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
|
||||
});
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Blobbi]', {
|
||||
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
|
||||
name,
|
||||
isLegacy,
|
||||
hasSeed: !!seed,
|
||||
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse task progress tags: ["task", "name:value"]
|
||||
const tasks: BlobbiTaskProgress[] = [];
|
||||
@@ -976,7 +978,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 +999,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 +1141,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 +1368,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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
@@ -27,6 +27,18 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Tour dev actions for the first-hatch tour */
|
||||
interface FirstHatchTourDevActions {
|
||||
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
|
||||
skipPostRequirement: () => void;
|
||||
/** Reset the entire first-hatch tour so it can be tested again from scratch */
|
||||
resetTour: () => void;
|
||||
/** Current tour step id, or null if not active */
|
||||
currentStepId: string | null;
|
||||
/** Whether the tour has been completed */
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface BlobbiDevEditorProps {
|
||||
/** Whether the editor modal is open */
|
||||
isOpen: boolean;
|
||||
@@ -38,6 +50,8 @@ interface BlobbiDevEditorProps {
|
||||
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
|
||||
/** Whether an update is in progress */
|
||||
isUpdating?: boolean;
|
||||
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
|
||||
tourDevActions?: FirstHatchTourDevActions;
|
||||
}
|
||||
|
||||
/** Updates that can be applied to a Blobbi */
|
||||
@@ -170,6 +184,7 @@ export function BlobbiDevEditor({
|
||||
companion,
|
||||
onApply,
|
||||
isUpdating = false,
|
||||
tourDevActions,
|
||||
}: BlobbiDevEditorProps) {
|
||||
// ─── Local State ───
|
||||
// Initialize from companion values
|
||||
@@ -527,8 +542,82 @@ export function BlobbiDevEditor({
|
||||
onCheckedChange={setBreedingReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── First-Hatch Tour Controls ─── */}
|
||||
{tourDevActions && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tourDevActions.isCompleted
|
||||
? 'Completed'
|
||||
: tourDevActions.currentStepId
|
||||
? tourDevActions.currentStepId
|
||||
: 'Not started'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Test the first-hatch tour flow without needing to create a real post.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* A. Skip Post Requirement */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.skipPostRequirement();
|
||||
}}
|
||||
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
|
||||
className="gap-2 text-xs"
|
||||
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
Skip Post
|
||||
</Button>
|
||||
|
||||
{/* B. Restart First-Hatch Tour */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
className="gap-2 text-xs"
|
||||
title="Reset the entire first-hatch tour state so it can be tested again"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Restart Tour
|
||||
</Button>
|
||||
|
||||
{/* C. Reset Blobbi to Egg */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStage('egg');
|
||||
setState('active');
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
disabled={companion.stage === 'egg'}
|
||||
className="gap-2 text-xs"
|
||||
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
|
||||
>
|
||||
<Egg className="size-3.5" />
|
||||
Reset to Egg + Tour
|
||||
</Button>
|
||||
</div>
|
||||
{companion.stage !== 'egg' && stage === 'egg' && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
|
||||
*
|
||||
* Rendered directly in the BlobbiPage layout so the experience feels
|
||||
* focused and guided. Adapts its messaging based on the current tour step.
|
||||
*
|
||||
* When the post mission is completed, the card stays visible with a
|
||||
* celebratory completed state for ~2s (the parent auto-advances after
|
||||
* that delay). This ensures the user sees the checkmark before the
|
||||
* flow progresses to the egg-tap phase.
|
||||
*/
|
||||
|
||||
import { Send, Check, MousePointerClick } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { FirstHatchTourStepId } from '../lib/tour-types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourCardProps {
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Current tour step id for adaptive messaging */
|
||||
currentStep: FirstHatchTourStepId | null;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourCard({
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
currentStep,
|
||||
}: FirstHatchTourCardProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
// Determine which phase of the card to show
|
||||
const isPostStep = currentStep === 'show_hatch_card';
|
||||
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|
||||
|| currentStep === 'egg_crack_stage_1'
|
||||
|| currentStep === 'egg_crack_stage_2'
|
||||
|| currentStep === 'egg_crack_stage_3';
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm mx-auto space-y-4">
|
||||
{/* Title + description */}
|
||||
<div className="text-center space-y-1.5">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{isClickStep
|
||||
? `Tap ${capitalizedName} to hatch!`
|
||||
: postCompleted && isPostStep
|
||||
? `${capitalizedName} heard you!`
|
||||
: `${capitalizedName} is ready to hatch!`}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{isClickStep
|
||||
? `Tap the egg to help ${capitalizedName} break free.`
|
||||
: postCompleted && isPostStep
|
||||
? 'Your post was shared. Get ready to hatch...'
|
||||
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card - only during post step */}
|
||||
{isPostStep && (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
{postCompleted ? (
|
||||
/* ── Completed state — celebratory, stays visible ── */
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
|
||||
<Check className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
Post shared!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Continuing in a moment...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Pending state — post mission ── */
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">Share a hatch post</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your post must include:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tap hint during click steps */}
|
||||
{isClickStep && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MousePointerClick className="size-4" />
|
||||
<span>Tap the egg</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra hint for post step */}
|
||||
{isPostStep && !postCompleted && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
|
||||
*
|
||||
* Tells the user their egg is about to hatch and guides them to create a post.
|
||||
* Contains a single mission: create the hatch post.
|
||||
*/
|
||||
|
||||
import { Egg, Send, Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Advance the tour (called after post is confirmed complete) */
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
onContinue,
|
||||
}: FirstHatchTourModalProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header with egg accent */}
|
||||
<div className="px-6 pt-8 pb-4 text-center space-y-3">
|
||||
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<Egg className="size-7 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{capitalizedName} is ready to hatch!
|
||||
</DialogTitle>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Share a post to the Nostr network and help {capitalizedName} break free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className={
|
||||
postCompleted
|
||||
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
|
||||
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
|
||||
}>
|
||||
{postCompleted && <Check className="size-3 text-emerald-500" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Post must include the phrase:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!postCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6">
|
||||
{postCompleted ? (
|
||||
<Button className="w-full" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
|
||||
*
|
||||
* Orchestration only -- no rendering, no animations.
|
||||
* The hook manages:
|
||||
* - Ordered step progression
|
||||
* - Persisted state via localStorage (survives refresh / close)
|
||||
* - Derived booleans for UI consumption
|
||||
* - Safe advance / goTo / complete / reset actions
|
||||
*
|
||||
* Activation is handled separately by useFirstHatchTourActivation,
|
||||
* which calls `start()` when all preconditions are met.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Future integration points
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
|
||||
* to decide whether to start the tour.
|
||||
* 2. UI components read `state.currentStepId` and render overlays,
|
||||
* spotlights, modals, or animation cues accordingly.
|
||||
* 3. Animation components call `actions.advance()` when their
|
||||
* sequence finishes (for autoAdvance steps).
|
||||
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
|
||||
* on the user interaction.
|
||||
* 5. EggGraphic receives a visual-state prop derived from
|
||||
* `state.currentStepId` -- it does NOT own the tour logic.
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
type FirstHatchTourStepId,
|
||||
type FirstHatchTourPersistedState,
|
||||
type TourState,
|
||||
type TourActions,
|
||||
} from '../lib/tour-types';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* localStorage key for the first hatch tour state.
|
||||
* Not user-scoped because onboarding state is device-local and the tour
|
||||
* is inherently tied to "first ever egg on this device". If multi-user
|
||||
* support on the same device becomes a concern, scope by pubkey.
|
||||
*/
|
||||
const STORAGE_KEY = 'blobbi:tour:first-hatch';
|
||||
|
||||
/** Pre-computed lookup: stepId -> index */
|
||||
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
|
||||
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
|
||||
);
|
||||
|
||||
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
|
||||
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
|
||||
|
||||
// ─── Result Type ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseFirstHatchTourResult {
|
||||
/** Reactive tour state for UI consumption */
|
||||
state: TourState<FirstHatchTourStepId>;
|
||||
/** Actions to drive the tour forward */
|
||||
actions: TourActions<FirstHatchTourStepId>;
|
||||
/**
|
||||
* Convenience: check if the current step matches a given id.
|
||||
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
|
||||
*/
|
||||
isStep: (stepId: FirstHatchTourStepId) => boolean;
|
||||
/**
|
||||
* Convenience: check if the current step is one of the given ids.
|
||||
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
|
||||
*/
|
||||
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
|
||||
/**
|
||||
* The current step definition (with autoAdvance metadata), or null.
|
||||
*/
|
||||
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFirstHatchTour(): UseFirstHatchTourResult {
|
||||
// ── Persisted state ──
|
||||
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
|
||||
STORAGE_KEY,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
);
|
||||
|
||||
// Stable ref to current persisted state so callbacks never go stale.
|
||||
const persistedRef = useRef(persisted);
|
||||
persistedRef.current = persisted;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const updatePersisted = useCallback(
|
||||
(patch: Partial<FirstHatchTourPersistedState>) => {
|
||||
setPersisted((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
},
|
||||
[setPersisted],
|
||||
);
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const start = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
// No-op if already active or completed
|
||||
if (p.completed || p.currentStepId !== null) return;
|
||||
|
||||
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
|
||||
if (!firstStep) return;
|
||||
|
||||
updatePersisted({ currentStepId: firstStep.id });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
if (p.completed || p.currentStepId === null) return;
|
||||
|
||||
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
|
||||
if (currentIndex === undefined) return;
|
||||
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
|
||||
// Past the end -- complete
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
|
||||
if (nextStep.id === 'complete') {
|
||||
// Reaching the 'complete' terminal step means the tour is done
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: nextStep.id });
|
||||
}
|
||||
}, [updatePersisted]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => {
|
||||
if (!STEP_INDEX_MAP.has(stepId)) {
|
||||
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
|
||||
}
|
||||
|
||||
if (stepId === 'complete') {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: stepId, completed: false });
|
||||
}
|
||||
},
|
||||
[updatePersisted],
|
||||
);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
|
||||
}, [setPersisted]);
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
const currentStepIndex = persisted.currentStepId !== null
|
||||
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
|
||||
: -1;
|
||||
|
||||
const state = useMemo((): TourState<FirstHatchTourStepId> => {
|
||||
const isActive = persisted.currentStepId !== null && !persisted.completed;
|
||||
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
|
||||
|
||||
return {
|
||||
isActive,
|
||||
currentStepId: persisted.currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
|
||||
isCompleted: persisted.completed,
|
||||
progress: persisted.completed
|
||||
? 1
|
||||
: currentStepIndex >= 0
|
||||
? currentStepIndex / LAST_REAL_STEP_INDEX
|
||||
: 0,
|
||||
};
|
||||
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
|
||||
|
||||
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
|
||||
start,
|
||||
advance,
|
||||
goTo,
|
||||
complete,
|
||||
reset,
|
||||
}), [start, advance, goTo, complete, reset]);
|
||||
|
||||
// ── Convenience helpers ──
|
||||
|
||||
const isStep = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const isAnyStep = useCallback(
|
||||
(...stepIds: FirstHatchTourStepId[]) => {
|
||||
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
|
||||
},
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const currentStepDef = currentStepIndex >= 0
|
||||
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
|
||||
: null;
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
isStep,
|
||||
isAnyStep,
|
||||
currentStepDef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
|
||||
*
|
||||
* This hook checks all preconditions and calls `tour.actions.start()` when
|
||||
* the tour should activate. It is intentionally separated from the tour
|
||||
* state machine so that:
|
||||
* - The state machine stays generic and reusable.
|
||||
* - Activation rules are centralized in one place.
|
||||
* - The rules are easy to read and modify.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Activation rules (ALL must be true):
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. The companions list is loaded (not loading / error).
|
||||
* 2. The user has exactly 1 Blobbi.
|
||||
* 3. That Blobbi is in the egg stage.
|
||||
* 4. No Blobbi is in baby or adult stage.
|
||||
* 5. The tour has not been completed yet (checked via profile tag
|
||||
* AND localStorage fallback).
|
||||
*
|
||||
* Completion is authoritative from the Blobbonaut profile event
|
||||
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
|
||||
* is a secondary signal for in-progress UI state and as a fallback
|
||||
* when the profile hasn't been updated yet.
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirstHatchTourActivationInput {
|
||||
/** The full list of the user's Blobbi companions */
|
||||
companions: BlobbiCompanion[];
|
||||
/** Whether the companions list is still loading */
|
||||
isLoading: boolean;
|
||||
/** The tour hook result (localStorage-based state machine) */
|
||||
tour: UseFirstHatchTourResult;
|
||||
/**
|
||||
* Whether onboarding is already marked complete in the Blobbonaut profile
|
||||
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
|
||||
* When true, the tour will not activate regardless of localStorage state.
|
||||
*/
|
||||
profileOnboardingDone?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstHatchTourActivationResult {
|
||||
/**
|
||||
* Whether all preconditions for activating the tour are met right now.
|
||||
* This is a derived boolean -- it does NOT mean the tour IS active,
|
||||
* just that it SHOULD be activated. The tour may already be active
|
||||
* from a previous render or a persisted state.
|
||||
*/
|
||||
shouldActivate: boolean;
|
||||
/**
|
||||
* Whether the tour is eligible (preconditions met and not yet completed).
|
||||
* Useful for hiding UI that should only appear during the tour window.
|
||||
*/
|
||||
isEligible: boolean;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluates activation preconditions and auto-starts the tour when met.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const tour = useFirstHatchTour();
|
||||
* const activation = useFirstHatchTourActivation({
|
||||
* companions,
|
||||
* isLoading: companionsLoading,
|
||||
* tour,
|
||||
* profileOnboardingDone: profile?.onboardingDone,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading,
|
||||
tour,
|
||||
profileOnboardingDone: _profileOnboardingDone = false,
|
||||
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
|
||||
// ── Precondition evaluation ──
|
||||
|
||||
const { shouldActivate, isEligible } = useMemo(() => {
|
||||
// Can't evaluate until data is loaded
|
||||
if (isLoading) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// localStorage tour already completed — this is always authoritative
|
||||
if (tour.state.isCompleted) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// Must have exactly 1 companion
|
||||
if (companions.length !== 1) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
const onlyBlobbi = companions[0];
|
||||
|
||||
// That companion must be an egg
|
||||
if (onlyBlobbi.stage !== 'egg') {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
|
||||
// but kept explicit for clarity and future-proofing if rules change)
|
||||
const hasBabyOrAdult = companions.some(
|
||||
(c) => c.stage === 'baby' || c.stage === 'adult',
|
||||
);
|
||||
if (hasBabyOrAdult) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
|
||||
// Some older accounts had `onboarding_done` migrated to
|
||||
// `blobbi_onboarding_done=true` before the first-hatch tour
|
||||
// existed, so they never experienced it. When the user is in the
|
||||
// exact single-egg/no-evolved-companions state (all checks above
|
||||
// passed), we intentionally ignore `profileOnboardingDone` so
|
||||
// those accounts can still enter the tour.
|
||||
//
|
||||
// This is safe because:
|
||||
// - The localStorage `tour.state.isCompleted` check above
|
||||
// already prevents re-triggering for users who HAVE finished
|
||||
// the tour.
|
||||
// - The egg-stage + single-companion guard means this only
|
||||
// fires for users who genuinely haven't hatched yet.
|
||||
//
|
||||
// TODO: Replace `blobbi_onboarding_done` with a dedicated
|
||||
// `blobbi_first_hatch_tour_done` tag so onboarding completion
|
||||
// and tour completion are tracked independently. Once that tag
|
||||
// is in place, remove this safeguard and gate activation on the
|
||||
// new tag instead.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// (profileOnboardingDone is intentionally NOT checked here)
|
||||
|
||||
// All preconditions met
|
||||
const eligible = true;
|
||||
// Only activate if the tour is not already running
|
||||
const activate = !tour.state.isActive;
|
||||
|
||||
return { shouldActivate: activate, isEligible: eligible };
|
||||
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
|
||||
|
||||
// ── Auto-start effect ──
|
||||
// When all preconditions are met and the tour hasn't started yet,
|
||||
// start it. This fires once and then `shouldActivate` flips to false
|
||||
// because `tour.state.isActive` becomes true.
|
||||
useEffect(() => {
|
||||
if (shouldActivate) {
|
||||
tour.actions.start();
|
||||
}
|
||||
}, [shouldActivate, tour.actions]);
|
||||
|
||||
return { shouldActivate, isEligible };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Blobbi Tour Module
|
||||
*
|
||||
* Provides the orchestration layer for guided tours / tutorials.
|
||||
* Currently implements the first-egg hatch tour.
|
||||
*
|
||||
* Architecture:
|
||||
* - tour-types.ts: Step definitions, persisted state shape, generic types
|
||||
* - useFirstHatchTour: State machine (step progression, persistence, actions)
|
||||
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
|
||||
*
|
||||
* UI components import from this barrel and read tour state to decide
|
||||
* what to render. They call tour actions (advance, goTo, complete) in
|
||||
* response to user interactions or animation completions.
|
||||
*/
|
||||
|
||||
// ── Types (generic tour infrastructure) ──
|
||||
export type {
|
||||
TourStepDef,
|
||||
TourPersistedState,
|
||||
TourState,
|
||||
TourActions,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Types & Constants ──
|
||||
export {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
} from './lib/tour-types';
|
||||
export type {
|
||||
FirstHatchTourStepId,
|
||||
FirstHatchTourPersistedState,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Hooks ──
|
||||
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
|
||||
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
|
||||
|
||||
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
|
||||
export type {
|
||||
FirstHatchTourActivationInput,
|
||||
FirstHatchTourActivationResult,
|
||||
} from './hooks/useFirstHatchTourActivation';
|
||||
|
||||
// ── First Hatch Tour - Components ──
|
||||
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tour System - Core Types
|
||||
*
|
||||
* Generic, reusable types for step-based guided tours.
|
||||
* The tour system is designed to be:
|
||||
* - Easy to extend with new tours (define steps + config)
|
||||
* - Easy to reorder steps (change the STEPS array)
|
||||
* - Persistent across page refreshes (localStorage)
|
||||
* - Decoupled from rendering (UI reads state, doesn't own it)
|
||||
*/
|
||||
|
||||
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A tour step definition.
|
||||
*
|
||||
* Each step has a unique id and optional metadata that future UI layers
|
||||
* can use to decide what to render (spotlights, modals, animations, etc.).
|
||||
*/
|
||||
export interface TourStepDef<StepId extends string = string> {
|
||||
/** Unique identifier for this step */
|
||||
id: StepId;
|
||||
/**
|
||||
* Whether this step auto-advances (e.g. animations) or waits for
|
||||
* an explicit `advance()` / `goTo()` call from the UI.
|
||||
* Default: false (manual).
|
||||
*/
|
||||
autoAdvance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted state for a tour.
|
||||
* Stored in localStorage so tours survive refresh / close / return.
|
||||
*/
|
||||
export interface TourPersistedState<StepId extends string = string> {
|
||||
/** Current step id, or null when the tour is not yet started */
|
||||
currentStepId: StepId | null;
|
||||
/** Whether the tour has been completed */
|
||||
completed: boolean;
|
||||
/** Unix ms timestamp of last state change (for debugging / analytics) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full runtime state exposed by a tour hook.
|
||||
*/
|
||||
export interface TourState<StepId extends string = string> {
|
||||
/** Whether the tour is currently active (started and not yet completed) */
|
||||
isActive: boolean;
|
||||
/** Current step id, or null when idle / completed */
|
||||
currentStepId: StepId | null;
|
||||
/** 0-based index of the current step in the steps array, or -1 */
|
||||
currentStepIndex: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Whether the current step is the last one before completion */
|
||||
isLastStep: boolean;
|
||||
/** Whether the tour has been completed (persisted) */
|
||||
isCompleted: boolean;
|
||||
/** Progress as a fraction 0..1 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions exposed by a tour hook.
|
||||
*/
|
||||
export interface TourActions<StepId extends string = string> {
|
||||
/** Start the tour from the first step (no-op if already active or completed) */
|
||||
start: () => void;
|
||||
/** Advance to the next step. Completes the tour if on the last step. */
|
||||
advance: () => void;
|
||||
/** Jump to a specific step by id. Throws if the step doesn't exist. */
|
||||
goTo: (stepId: StepId) => void;
|
||||
/** Mark the tour as completed and reset to idle. */
|
||||
complete: () => void;
|
||||
/** Reset the tour entirely (clears persisted state). For dev/testing. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step ids for the first-egg hatch tour.
|
||||
*
|
||||
* Flow:
|
||||
* 1. idle — initial state (auto-advances immediately)
|
||||
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
|
||||
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
|
||||
* 4. egg_crack_stage_1 — click 1: crack expands
|
||||
* 5. egg_crack_stage_2 — click 2: crack expands further
|
||||
* 6. egg_crack_stage_3 — click 3: crack reaches edges
|
||||
* 7. egg_opening — shell opens (auto-advance after animation)
|
||||
* 8. egg_hatching — bright light + baby reveal (auto-advance)
|
||||
* 9. complete — terminal, marks tour done
|
||||
*
|
||||
* The order here matches the intended flow. To reorder steps,
|
||||
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
|
||||
*/
|
||||
export type FirstHatchTourStepId =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'egg_glowing_waiting_click'
|
||||
| 'egg_crack_stage_1'
|
||||
| 'egg_crack_stage_2'
|
||||
| 'egg_crack_stage_3'
|
||||
| 'egg_opening'
|
||||
| 'egg_hatching'
|
||||
| 'complete';
|
||||
|
||||
/**
|
||||
* Ordered step definitions for the first hatch tour.
|
||||
*
|
||||
* To add / remove / reorder steps, edit this array.
|
||||
* The tour state machine walks through these in order.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
|
||||
{ id: 'idle' },
|
||||
{ id: 'show_hatch_card' },
|
||||
{ id: 'egg_glowing_waiting_click' },
|
||||
{ id: 'egg_crack_stage_1' },
|
||||
{ id: 'egg_crack_stage_2' },
|
||||
{ id: 'egg_crack_stage_3' },
|
||||
{ id: 'egg_opening', autoAdvance: true },
|
||||
{ id: 'egg_hatching', autoAdvance: true },
|
||||
{ id: 'complete' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Persisted state shape for the first hatch tour.
|
||||
*/
|
||||
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
|
||||
|
||||
/**
|
||||
* Default persisted state for a brand-new first hatch tour.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
|
||||
currentStepId: null,
|
||||
completed: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
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 { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { NostrURI } from '@/lib/NostrURI';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Get a tag value by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
/** Get all values for a tag name. */
|
||||
function getAllTags(tags: string[][], name: string): string[] {
|
||||
return tags.filter(([n]) => n === name).map(([, v]) => v);
|
||||
}
|
||||
|
||||
/** Parse kind-0-style metadata from the content field. */
|
||||
function parseHandlerMetadata(content: string): NostrMetadata {
|
||||
if (!content) return {};
|
||||
try {
|
||||
return JSON.parse(content) as NostrMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the website URL from web handler tags or metadata. */
|
||||
function getWebsiteUrl(tags: string[][], metadata: NostrMetadata): string | undefined {
|
||||
const webTags = tags.filter(([n]) => n === 'web');
|
||||
for (const tag of webTags) {
|
||||
const url = tag[1];
|
||||
if (url) {
|
||||
const base = url.replace(/<bech32>/g, '').replace(/\/+$/, '');
|
||||
return base;
|
||||
}
|
||||
}
|
||||
return metadata.website;
|
||||
}
|
||||
|
||||
/** Extract the display domain from a URL. */
|
||||
function displayDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a Shakespeare "Edit with Shakespeare" URL from a kind 30617 `a` tag, if present. */
|
||||
function getShakespeareUrl(tags: string[][]): string | undefined {
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'a') continue;
|
||||
const parts = tag[1]?.split(':');
|
||||
if (!parts || parts[0] !== '30617' || parts.length < 3) continue;
|
||||
const pubkey = parts[1];
|
||||
const identifier = parts.slice(2).join(':');
|
||||
const nostrUri = new NostrURI({ pubkey, identifier }).toString();
|
||||
return `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrUri)}`;
|
||||
}
|
||||
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). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** Renders a kind 31990 NIP-89 application handler event as a showcase-style card. */
|
||||
export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
const metadata = useMemo(() => parseHandlerMetadata(event.content), [event.content]);
|
||||
|
||||
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);
|
||||
|
||||
// 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">
|
||||
{/* Banner hero */}
|
||||
{banner && (
|
||||
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
|
||||
<img
|
||||
src={banner}
|
||||
alt=""
|
||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<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}
|
||||
alt={name}
|
||||
className="size-14 rounded-xl object-cover shrink-0 border-3 border-background bg-background shadow-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
|
||||
<Package className="size-6 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + domain */}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[15px] leading-snug truncate">{name}</h3>
|
||||
{websiteUrl && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<ExternalFavicon url={websiteUrl} size={12} />
|
||||
<span className="truncate">{displayDomain(websiteUrl)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{about && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{about}</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hashtags.slice(0, 4).map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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" 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" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{shakespeareUrl && (
|
||||
<Button asChild variant="secondary" size="sm" className="h-7 text-xs">
|
||||
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Fork
|
||||
<GitFork className="size-3 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{/* Banner hero */}
|
||||
{banner && (
|
||||
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
|
||||
<img
|
||||
src={banner}
|
||||
alt=""
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<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',
|
||||
banner ? '-mt-10' : 'pt-4',
|
||||
)}>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt={name}
|
||||
className="size-20 rounded-2xl object-cover shrink-0 border-4 border-background bg-background shadow-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
|
||||
<Package className="size-8 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + domain */}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold leading-snug truncate">{name}</h2>
|
||||
{websiteUrl && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
<ExternalFavicon url={websiteUrl} size={14} />
|
||||
<span className="truncate">{displayDomain(websiteUrl)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{about && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{about}</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hashtags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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" 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" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{shakespeareUrl && (
|
||||
<Button asChild variant="secondary" size="sm">
|
||||
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Fork
|
||||
<GitFork className="size-3.5 ml-1.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nsiteRef && nsiteEvent && (
|
||||
<NsitePreviewDialog
|
||||
event={nsiteEvent}
|
||||
appName={name}
|
||||
appPicture={picture}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton loading state for AppHandlerContent. */
|
||||
export function AppHandlerSkeleton() {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Skeleton className="aspect-[2/1] w-full" />
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<div className="-mt-10">
|
||||
<Skeleton className="size-20 rounded-2xl border-4 border-background" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
|
||||
import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
|
||||
import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
|
||||
import { AppConfigSchema } from '@/lib/schemas';
|
||||
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
|
||||
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
|
||||
@@ -47,13 +47,6 @@ export function AppProvider(props: AppProviderProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy theme values ("black", "pink") to "custom" + customTheme
|
||||
const legacyTheme = result.theme as string | undefined;
|
||||
if (legacyTheme && legacyTheme in themePresets) {
|
||||
result.theme = 'custom';
|
||||
result.customTheme = { colors: themePresets[legacyTheme].colors };
|
||||
}
|
||||
|
||||
// Migrate legacy blossomServers (string[]) to blossomServerMetadata
|
||||
if (!result.blossomServerMetadata) {
|
||||
const legacyServers = parsed.blossomServers;
|
||||
|
||||
@@ -108,14 +108,16 @@ 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',
|
||||
30817: 'a custom NIP',
|
||||
31922: 'a calendar event',
|
||||
31923: 'a calendar event',
|
||||
32267: 'an app',
|
||||
31990: 'an app',
|
||||
32267: 'a Zapstore app',
|
||||
34139: 'a playlist',
|
||||
34236: 'a divine',
|
||||
34550: 'a community',
|
||||
@@ -154,9 +156,11 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
30030: SmilePlus,
|
||||
30054: Podcast,
|
||||
30055: Podcast,
|
||||
3063: Package,
|
||||
30063: Package,
|
||||
30311: Radio,
|
||||
30617: GitBranch,
|
||||
31990: Package,
|
||||
32267: Package,
|
||||
34236: Clapperboard,
|
||||
36767: Sparkles,
|
||||
@@ -210,10 +214,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. */
|
||||
@@ -228,6 +233,16 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
|
||||
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
|
||||
}
|
||||
|
||||
// NIP-89 apps: name lives in JSON content, not in tags
|
||||
if (event.kind === 31990) {
|
||||
try {
|
||||
const meta = JSON.parse(event.content);
|
||||
const appName = meta?.name || event.tags.find(([n]) => n === 'name')?.[1];
|
||||
if (appName) return { text: `${appName} app`, icon };
|
||||
} catch { /* fall through */ }
|
||||
return { text: 'an app', icon };
|
||||
}
|
||||
|
||||
// Extract a title-like string from tags
|
||||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
const name = event.tags.find(([name]) => name === 'name')?.[1];
|
||||
|
||||
+110
-95
@@ -201,7 +201,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: '' },
|
||||
@@ -224,6 +223,25 @@ export function ComposeBox({
|
||||
const voiceRecorder = useVoiceRecorder();
|
||||
const [isPublishingVoice, setIsPublishingVoice] = useState(false);
|
||||
|
||||
const resetComposeState = useCallback(() => {
|
||||
setContent('');
|
||||
setCwEnabled(false);
|
||||
setCwText('');
|
||||
setExpanded(false);
|
||||
setPickerOpen(false);
|
||||
setTrayOpen(false);
|
||||
setInternalPreviewMode(false);
|
||||
setMode(initialMode);
|
||||
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
|
||||
setPollType('singlechoice');
|
||||
setPollDuration(7);
|
||||
setRemovedEmbeds(new Set());
|
||||
setUploadedFileTags([]);
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
}, [initialMode]);
|
||||
|
||||
// Use controlled preview mode if provided, otherwise use internal state
|
||||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||||
|
||||
@@ -928,15 +946,7 @@ export function ComposeBox({
|
||||
});
|
||||
}
|
||||
|
||||
setContent('');
|
||||
setCwEnabled(false);
|
||||
setCwText('');
|
||||
setExpanded(false);
|
||||
setRemovedEmbeds(new Set());
|
||||
setUploadedFileTags([]);
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
resetComposeState();
|
||||
// Optimistically bump the reply count on the parent event
|
||||
if (replyTo && !(replyTo instanceof URL)) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
|
||||
@@ -970,7 +980,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) {
|
||||
@@ -980,16 +991,28 @@ 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 });
|
||||
// Reset poll state
|
||||
setMode('post');
|
||||
setPollQuestion('');
|
||||
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
|
||||
setPollType('singlechoice');
|
||||
setPollDuration(7);
|
||||
await createEvent({ kind: 1068, content: finalContent, tags });
|
||||
resetComposeState();
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
toast({ title: 'Poll published!' });
|
||||
onSuccess?.();
|
||||
@@ -999,7 +1022,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;
|
||||
|
||||
@@ -1007,7 +1030,7 @@ export function ComposeBox({
|
||||
if (!user && compact) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-3 bg-background/50")}>
|
||||
<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">
|
||||
@@ -1055,31 +1078,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) => (
|
||||
@@ -1160,66 +1235,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 */}
|
||||
|
||||
@@ -15,7 +15,7 @@ interface CustomEmojiImgProps {
|
||||
/**
|
||||
* Renders a single custom emoji as an inline image.
|
||||
*/
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] align-text-bottom' }: CustomEmojiImgProps) {
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
@@ -52,7 +52,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
|
||||
const url = getCustomEmojiUrl(emoji, tags);
|
||||
if (url) {
|
||||
const name = emoji.slice(1, -1);
|
||||
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-text-bottom'} />;
|
||||
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom'} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
|
||||
*/
|
||||
export function RenderResolvedEmoji({ emoji, className }: { emoji: ResolvedEmoji; className?: string }) {
|
||||
if (emoji.url && emoji.name) {
|
||||
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-middle'} />;
|
||||
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-middle'} />;
|
||||
}
|
||||
return <span className={cn('inline-block leading-none', className)}>{emoji.content}</span>;
|
||||
}
|
||||
|
||||
@@ -1082,8 +1082,10 @@ function hasVideo(tags: string[][]): boolean {
|
||||
|
||||
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
|
||||
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
|
||||
32267: 'App',
|
||||
30063: 'Release',
|
||||
31990: 'App',
|
||||
32267: 'Zapstore App',
|
||||
30063: 'Zapstore Release',
|
||||
3063: 'Zapstore Asset',
|
||||
15128: 'Nsite',
|
||||
35128: 'Nsite',
|
||||
31124: 'Blobbi',
|
||||
@@ -1109,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 === 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. */
|
||||
@@ -108,10 +109,14 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const { startSignup } = useOnboarding();
|
||||
|
||||
// Kind-specific pages only support Follows + Global. Clamp any other
|
||||
// persisted tab (e.g. 'ditto', 'communities') back to 'follows'.
|
||||
const activeTab: FeedTab = kinds && rawActiveTab !== 'follows' && rawActiveTab !== 'global'
|
||||
? 'follows'
|
||||
: rawActiveTab;
|
||||
// persisted tab (e.g. 'ditto', 'communities') back to the appropriate default.
|
||||
// Logged-out users must land on 'global' since 'follows' requires a user.
|
||||
const activeTab: FeedTab = (() => {
|
||||
if (!kinds) return rawActiveTab; // Home feed: no clamping
|
||||
if (rawActiveTab === 'global') return 'global';
|
||||
if (rawActiveTab === 'follows' && user) return 'follows';
|
||||
return user ? 'follows' : 'global';
|
||||
})();
|
||||
|
||||
// Is the active tab a saved feed?
|
||||
const activeSavedFeed = useMemo(
|
||||
|
||||
@@ -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,8 +126,8 @@ export function ImageGallery({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && lightboxIndex !== undefined && (
|
||||
{/* Lightbox — portaled to document.body to escape stacking contexts (e.g. the center column z-0) */}
|
||||
{lightboxIndex !== null && lightboxIndex !== undefined && createPortal(
|
||||
<Lightbox
|
||||
images={images}
|
||||
currentIndex={lightboxIndex}
|
||||
@@ -135,7 +136,8 @@ export function ImageGallery({
|
||||
onPrev={goPrev}
|
||||
topBarLeft={lightboxTopBarLeft}
|
||||
bottomBar={lightboxBottomBar}
|
||||
/>
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -184,7 +184,7 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
|
||||
{/* Emoji group header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
|
||||
{customUrl && customName ? (
|
||||
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6" />
|
||||
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6 object-contain" />
|
||||
) : (
|
||||
<span className="text-lg">{emoji}</span>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface LinkFooterProps {
|
||||
/** Optional callback fired when an internal (React Router) link is clicked. */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
/** Shared footer links used in both sidebars. */
|
||||
export function LinkFooter() {
|
||||
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">
|
||||
@@ -23,7 +28,7 @@ export function LinkFooter() {
|
||||
Docs
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/privacy" className="text-primary hover:underline">
|
||||
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
Privacy
|
||||
</Link>
|
||||
{' · '}
|
||||
@@ -36,7 +41,7 @@ export function LinkFooter() {
|
||||
Source
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/changelog" className="text-primary hover:underline">
|
||||
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
Changelog
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -319,7 +319,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<LinkFooter />
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -363,7 +363,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<LinkFooter />
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isSyncDone } from "@/hooks/useInitialSync";
|
||||
import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
|
||||
import type { ThemeConfig } from "@/themes";
|
||||
import { themePresets } from "@/themes";
|
||||
|
||||
|
||||
/**
|
||||
* NostrSync - Syncs user's Nostr data
|
||||
@@ -285,19 +285,7 @@ export function NostrSync() {
|
||||
const updates = { ...current };
|
||||
|
||||
if (encryptedSettings.theme) {
|
||||
// Migrate legacy theme values ("black", "pink") from older encrypted settings
|
||||
const remoteTheme = encryptedSettings.theme as string;
|
||||
if (remoteTheme in themePresets) {
|
||||
if (
|
||||
current.theme !== "custom" ||
|
||||
JSON.stringify(current.customTheme?.colors) !==
|
||||
JSON.stringify(themePresets[remoteTheme].colors)
|
||||
) {
|
||||
updates.theme = "custom";
|
||||
updates.customTheme = { colors: themePresets[remoteTheme].colors };
|
||||
changed = true;
|
||||
}
|
||||
} else if (encryptedSettings.theme !== current.theme) {
|
||||
if (encryptedSettings.theme !== current.theme) {
|
||||
updates.theme = encryptedSettings.theme;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ 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";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -247,7 +249,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;
|
||||
}
|
||||
@@ -262,7 +266,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;
|
||||
}
|
||||
@@ -306,6 +312,9 @@ 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;
|
||||
const isVanish = event.kind === 62;
|
||||
@@ -336,6 +345,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isAudioKind &&
|
||||
!isDevKind &&
|
||||
!isZapstoreApp &&
|
||||
!isZapstoreRelease &&
|
||||
!isZapstoreAsset &&
|
||||
!isAppHandler &&
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
!isVanish &&
|
||||
@@ -531,7 +543,25 @@ 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 ? (
|
||||
<EncryptedMessageContent event={event} compact />
|
||||
) : isLetter ? (
|
||||
@@ -789,11 +819,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
<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">
|
||||
<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="text-lg leading-none"
|
||||
className="h-5 w-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
{threaded && (
|
||||
@@ -870,11 +900,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
>
|
||||
<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">
|
||||
<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="text-xl leading-none"
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1999,6 +2029,18 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
: "streamed",
|
||||
},
|
||||
32267: {
|
||||
icon: Package,
|
||||
action: "published a Zapstore app",
|
||||
},
|
||||
30063: {
|
||||
icon: Package,
|
||||
action: "published a Zapstore release",
|
||||
},
|
||||
3063: {
|
||||
icon: Package,
|
||||
action: "published a Zapstore asset",
|
||||
},
|
||||
31990: {
|
||||
icon: Package,
|
||||
action: "published an app",
|
||||
},
|
||||
|
||||
@@ -545,7 +545,7 @@ export function NoteContent({
|
||||
{groupedTokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 align-text-bottom' : undefined))}</span>;
|
||||
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
|
||||
case 'image-embed': {
|
||||
if (disableEmbeds) {
|
||||
// In preview contexts (e.g. triple-dot menu), replace image URLs
|
||||
|
||||
+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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function ReactionButton({
|
||||
{filledHeart ? (
|
||||
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
|
||||
) : hasReacted && userReaction ? (
|
||||
<RenderResolvedEmoji emoji={userReaction} className="size-5 leading-none translate-y-px" />
|
||||
<RenderResolvedEmoji emoji={userReaction} className="h-5 w-5 object-contain leading-none translate-y-px" />
|
||||
) : (
|
||||
<Heart className="size-5" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { parseChangelog } from '@/lib/changelog';
|
||||
|
||||
const STORAGE_KEY = 'ditto:app-version';
|
||||
|
||||
/** Fetch the first changelog item for the given version (or the latest entry). */
|
||||
async function fetchChangelogExcerpt(version: string): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await fetch('/CHANGELOG.md');
|
||||
if (!res.ok) return undefined;
|
||||
const markdown = await res.text();
|
||||
const entries = parseChangelog(markdown);
|
||||
|
||||
// Try to find the entry matching the current version, otherwise use the first entry.
|
||||
const entry = entries.find((e) => e.version === version) ?? entries[0];
|
||||
if (!entry) return undefined;
|
||||
|
||||
// Return a truncated first item from the first section.
|
||||
const item = entry.sections[0]?.items[0];
|
||||
if (!item) return undefined;
|
||||
if (item.length <= 60) return item;
|
||||
return item.slice(0, 60).trimEnd() + '…';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compares the running app version against localStorage and shows a toast when the version changes. */
|
||||
export function VersionCheck() {
|
||||
useEffect(() => {
|
||||
const currentVersion = import.meta.env.VERSION;
|
||||
if (!currentVersion) return;
|
||||
|
||||
const storedVersion = localStorage.getItem(STORAGE_KEY);
|
||||
localStorage.setItem(STORAGE_KEY, currentVersion);
|
||||
|
||||
if (storedVersion && storedVersion !== currentVersion) {
|
||||
// Show the toast immediately, then enrich it with a changelog excerpt.
|
||||
const { update, id } = toast({
|
||||
title: `What's new in v${currentVersion}`,
|
||||
action: (
|
||||
<ToastAction altText="View changelog" asChild>
|
||||
<Link to="/changelog">See all</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
fetchChangelogExcerpt(currentVersion).then((excerpt) => {
|
||||
if (excerpt) {
|
||||
update({
|
||||
id,
|
||||
title: `What's new in v${currentVersion}`,
|
||||
description: excerpt,
|
||||
action: (
|
||||
<ToastAction altText="View changelog" asChild>
|
||||
<Link to="/changelog">See all</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface LinkDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedText?: string;
|
||||
onSubmit: (text: string, url: string) => void;
|
||||
}
|
||||
|
||||
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
|
||||
const [text, setText] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setText(selectedText || '');
|
||||
setUrl('');
|
||||
}
|
||||
}, [open, selectedText]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (url.trim()) {
|
||||
const finalText = text.trim() || url.trim();
|
||||
let finalUrl = url.trim();
|
||||
|
||||
// Add https:// if no protocol specified
|
||||
if (!/^https?:\/\//i.test(finalUrl)) {
|
||||
finalUrl = 'https://' + finalUrl;
|
||||
}
|
||||
|
||||
onSubmit(finalText, finalUrl);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelectedText = !!selectedText;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Link</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{!hasSelectedText && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-text">Link Text</Label>
|
||||
<Input
|
||||
id="link-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Enter link text..."
|
||||
autoFocus={!hasSelectedText}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasSelectedText && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Link Text</Label>
|
||||
<p className="text-sm bg-muted px-3 py-2 rounded-md">{selectedText}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-url">URL</Label>
|
||||
<Input
|
||||
id="link-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
autoFocus={hasSelectedText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!url.trim()}>
|
||||
Insert Link
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
|
||||
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
|
||||
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, insertHrCommand, turnIntoTextCommand, wrapInHeadingCommand, toggleInlineCodeCommand, wrapInBulletListCommand, wrapInOrderedListCommand } from '@milkdown/preset-commonmark';
|
||||
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
|
||||
import { history } from '@milkdown/plugin-history';
|
||||
import { clipboard } from '@milkdown/plugin-clipboard';
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener';
|
||||
import { upload, uploadConfig } from '@milkdown/plugin-upload';
|
||||
import { Decoration } from '@milkdown/prose/view';
|
||||
import { replaceAll, callCommand } from '@milkdown/utils';
|
||||
import { MilkdownToolbar } from './MilkdownToolbar';
|
||||
import { LinkDialog } from './LinkDialog';
|
||||
|
||||
interface MilkdownEditorInnerProps {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onBlur?: () => void;
|
||||
onUploadImage?: (file: File) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
showToolbar?: boolean;
|
||||
sourceMode?: boolean;
|
||||
onToggleSource?: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
const onUploadImageRef = useRef(onUploadImage);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Link dialog state
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
|
||||
const selectionRef = useRef<{ from: number; to: number } | null>(null);
|
||||
|
||||
// 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;
|
||||
}, [value, onUploadImage]);
|
||||
|
||||
const { get } = useEditor((root) => {
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root);
|
||||
ctx.set(defaultValueCtx, initialValueRef.current);
|
||||
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
|
||||
lastExternalValue.current = markdown;
|
||||
onChange(markdown);
|
||||
});
|
||||
|
||||
// Configure upload plugin
|
||||
ctx.set(uploadConfig.key, {
|
||||
uploader: async (files, schema) => {
|
||||
const images: File[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files.item(i);
|
||||
if (!file) continue;
|
||||
|
||||
// Only handle images
|
||||
if (!file.type.includes('image')) continue;
|
||||
|
||||
images.push(file);
|
||||
}
|
||||
|
||||
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
|
||||
|
||||
for (const image of images) {
|
||||
try {
|
||||
// Use the upload handler if provided
|
||||
if (onUploadImageRef.current) {
|
||||
const url = await onUploadImageRef.current(image);
|
||||
if (url) {
|
||||
const node = schema.nodes.image.createAndFill({
|
||||
src: url,
|
||||
alt: image.name,
|
||||
});
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
} else {
|
||||
// Fallback to base64 if no upload handler
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(image);
|
||||
});
|
||||
const node = schema.nodes.image.createAndFill({
|
||||
src: dataUrl,
|
||||
alt: image.name,
|
||||
});
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.filter((node): node is NonNullable<typeof node> => node !== null);
|
||||
},
|
||||
enableHtmlFileUploader: true,
|
||||
uploadWidgetFactory: (pos, spec) => {
|
||||
// Create a placeholder widget while uploading
|
||||
const widgetEl = document.createElement('span');
|
||||
widgetEl.className = 'milkdown-upload-placeholder';
|
||||
widgetEl.textContent = 'Uploading...';
|
||||
return Decoration.widget(pos, widgetEl, spec);
|
||||
},
|
||||
});
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(clipboard)
|
||||
.use(listener)
|
||||
.use(upload);
|
||||
|
||||
return editor;
|
||||
});
|
||||
|
||||
// Store editor reference
|
||||
useEffect(() => {
|
||||
editorRef.current = get() ?? null;
|
||||
}, [get]);
|
||||
|
||||
// Toggle `has-content` class on blur so CSS can hide the placeholder
|
||||
// when the editor has real content (including trailing whitespace that
|
||||
// ProseMirror collapses out of the DOM).
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
let dom: HTMLElement;
|
||||
try {
|
||||
dom = editor.ctx.get(editorViewCtx).dom;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const check = () => {
|
||||
const hasContent = !!lastExternalValue.current.replace(/\n/g, '');
|
||||
dom.classList.toggle('has-content', hasContent);
|
||||
};
|
||||
// Set initial state
|
||||
check();
|
||||
dom.addEventListener('blur', check);
|
||||
return () => dom.removeEventListener('blur', check);
|
||||
}, [get]);
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
editor.action(replaceAll(value));
|
||||
} catch {
|
||||
// editorView may not be ready yet (e.g. first render); ignore
|
||||
return;
|
||||
}
|
||||
lastExternalValue.current = value;
|
||||
}
|
||||
}, [value, get, sourceMode]);
|
||||
|
||||
// Handle link dialog open
|
||||
const handleLinkButtonClick = useCallback(() => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state } = view;
|
||||
const { from, to } = state.selection;
|
||||
const selectedText = state.doc.textBetween(from, to);
|
||||
|
||||
// Store selection for later use
|
||||
selectionRef.current = { from, to };
|
||||
setSelectedTextForLink(selectedText);
|
||||
setLinkDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to get selection:', error);
|
||||
}
|
||||
}, [get]);
|
||||
|
||||
// Handle link insertion from dialog
|
||||
const handleLinkSubmit = useCallback((text: string, url: string) => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state, dispatch } = view;
|
||||
const { schema } = state;
|
||||
|
||||
// Create a link mark
|
||||
const linkMark = schema.marks.link.create({ href: url });
|
||||
|
||||
// Create text node with link mark
|
||||
const linkNode = schema.text(text, [linkMark]);
|
||||
|
||||
const tr = state.tr;
|
||||
|
||||
if (selectionRef.current) {
|
||||
const { from, to } = selectionRef.current;
|
||||
// Replace selection with linked text
|
||||
tr.replaceWith(from, to, linkNode);
|
||||
} else {
|
||||
// Insert at current position
|
||||
const { from } = state.selection;
|
||||
tr.insert(from, linkNode);
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
view.focus();
|
||||
} catch (error) {
|
||||
console.error('Failed to insert link:', error);
|
||||
}
|
||||
}, [get]);
|
||||
|
||||
// Handle image upload via file picker + ProseMirror insertion
|
||||
const handleImageButtonClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleImageFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !onUploadImageRef.current) return;
|
||||
|
||||
const url = await onUploadImageRef.current(file);
|
||||
if (!url) return;
|
||||
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state, dispatch } = view;
|
||||
const { schema } = state;
|
||||
const node = schema.nodes.image.createAndFill({ src: url, alt: file.name });
|
||||
if (node) {
|
||||
const { from } = state.selection;
|
||||
dispatch(state.tr.insert(from, node));
|
||||
view.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to insert image:', error);
|
||||
}
|
||||
|
||||
// Reset so the same file can be re-selected
|
||||
e.target.value = '';
|
||||
}, [get]);
|
||||
|
||||
// Handle toolbar commands
|
||||
const handleCommand = useCallback((command: string) => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
|
||||
switch (command) {
|
||||
case 'toggleBold':
|
||||
editor.action(callCommand(toggleStrongCommand.key));
|
||||
break;
|
||||
case 'toggleItalic':
|
||||
editor.action(callCommand(toggleEmphasisCommand.key));
|
||||
break;
|
||||
case 'toggleStrikethrough':
|
||||
editor.action(callCommand(toggleStrikethroughCommand.key));
|
||||
break;
|
||||
case 'toggleInlineCode':
|
||||
editor.action(callCommand(toggleInlineCodeCommand.key));
|
||||
break;
|
||||
case 'heading1':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 1));
|
||||
break;
|
||||
case 'heading2':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 2));
|
||||
break;
|
||||
case 'heading3':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 3));
|
||||
break;
|
||||
case 'bulletList':
|
||||
editor.action(callCommand(wrapInBulletListCommand.key));
|
||||
break;
|
||||
case 'orderedList':
|
||||
editor.action(callCommand(wrapInOrderedListCommand.key));
|
||||
break;
|
||||
case 'blockquote':
|
||||
editor.action(callCommand(wrapInBlockquoteCommand.key));
|
||||
break;
|
||||
case 'link':
|
||||
handleLinkButtonClick();
|
||||
return; // Don't refocus, dialog will handle it
|
||||
case 'hr':
|
||||
editor.action(callCommand(insertHrCommand.key));
|
||||
break;
|
||||
case 'paragraph':
|
||||
editor.action(callCommand(turnIntoTextCommand.key));
|
||||
break;
|
||||
}
|
||||
|
||||
// Refocus the editor
|
||||
view.focus();
|
||||
} catch (error) {
|
||||
console.error('Command failed:', error);
|
||||
}
|
||||
}, [get, handleLinkButtonClick]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToolbar && (
|
||||
<MilkdownToolbar
|
||||
onCommand={handleCommand}
|
||||
onImageUpload={onUploadImage ? handleImageButtonClick : undefined}
|
||||
sourceMode={sourceMode}
|
||||
onToggleSource={onToggleSource}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{sourceMode ? (
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="milkdown-content"
|
||||
onBlur={onBlur}
|
||||
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
|
||||
>
|
||||
<Milkdown />
|
||||
</div>
|
||||
)}
|
||||
<LinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
selectedText={selectedTextForLink}
|
||||
onSubmit={handleLinkSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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, onBlur, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
|
||||
const [sourceMode, setSourceMode] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`milkdown-editor ${className || ''}`}>
|
||||
<MilkdownProvider>
|
||||
<MilkdownEditorInner
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onUploadImage={onUploadImage}
|
||||
placeholder={placeholder}
|
||||
showToolbar={showToolbar}
|
||||
sourceMode={sourceMode}
|
||||
onToggleSource={() => setSourceMode((s) => !s)}
|
||||
/>
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Code,
|
||||
Link,
|
||||
Image,
|
||||
Minus,
|
||||
HelpCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function MarkdownHelpPopover() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">Markdown Quick Reference</h4>
|
||||
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
|
||||
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">bold</span></div>
|
||||
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">italic</span></div>
|
||||
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
|
||||
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
|
||||
<div className="flex justify-between"><span>- list item</span><span className="font-sans">* item</span></div>
|
||||
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">1. item</span></div>
|
||||
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">link</span></div>
|
||||
<div className="flex justify-between"><span></span><span className="font-sans">image</span></div>
|
||||
<div className="flex justify-between"><span>> quote</span><span className="font-sans border-l-2 pl-1">quote</span></div>
|
||||
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">code</span></div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">
|
||||
Drag & drop or paste images to upload
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const hasPointerFine = typeof window !== 'undefined'
|
||||
&& window.matchMedia('(pointer: fine)').matches;
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"h-8 w-8 text-muted-foreground hover:text-foreground",
|
||||
active && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!hasPointerFine) return button;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>{label}</span>
|
||||
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface MilkdownToolbarProps {
|
||||
onCommand: (command: string) => void;
|
||||
onImageUpload?: () => void;
|
||||
sourceMode?: boolean;
|
||||
onToggleSource?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MilkdownToolbar({ onCommand, onImageUpload, sourceMode, onToggleSource, className }: MilkdownToolbarProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/95 backdrop-blur-sm flex-wrap sticky top-0 z-10 rounded-t-xl",
|
||||
className
|
||||
)}>
|
||||
{!sourceMode && (
|
||||
<>
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
icon={<Bold className="h-4 w-4" />}
|
||||
label="Bold"
|
||||
shortcut="Ctrl+B"
|
||||
onClick={() => onCommand('toggleBold')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Italic className="h-4 w-4" />}
|
||||
label="Italic"
|
||||
shortcut="Ctrl+I"
|
||||
onClick={() => onCommand('toggleItalic')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Strikethrough className="h-4 w-4" />}
|
||||
label="Strikethrough"
|
||||
onClick={() => onCommand('toggleStrikethrough')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Code className="h-4 w-4" />}
|
||||
label="Inline Code"
|
||||
onClick={() => onCommand('toggleInlineCode')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
icon={<Heading1 className="h-4 w-4" />}
|
||||
label="Heading 1"
|
||||
onClick={() => onCommand('heading1')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Heading2 className="h-4 w-4" />}
|
||||
label="Heading 2"
|
||||
onClick={() => onCommand('heading2')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Heading3 className="h-4 w-4" />}
|
||||
label="Heading 3"
|
||||
onClick={() => onCommand('heading3')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
icon={<List className="h-4 w-4" />}
|
||||
label="Bullet List"
|
||||
onClick={() => onCommand('bulletList')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<ListOrdered className="h-4 w-4" />}
|
||||
label="Numbered List"
|
||||
onClick={() => onCommand('orderedList')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Quote className="h-4 w-4" />}
|
||||
label="Blockquote"
|
||||
onClick={() => onCommand('blockquote')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Links and media */}
|
||||
<ToolbarButton
|
||||
icon={<Link className="h-4 w-4" />}
|
||||
label="Insert Link"
|
||||
onClick={() => onCommand('link')}
|
||||
/>
|
||||
{onImageUpload && (
|
||||
<ToolbarButton
|
||||
icon={<Image className="h-4 w-4" />}
|
||||
label="Insert Image"
|
||||
onClick={onImageUpload}
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon={<Minus className="h-4 w-4" />}
|
||||
label="Horizontal Rule"
|
||||
onClick={() => onCommand('hr')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<MarkdownHelpPopover />
|
||||
</>
|
||||
)}
|
||||
|
||||
{sourceMode && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground px-1.5">Markdown Source</span>
|
||||
<span className="flex-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{onToggleSource && (
|
||||
<ToolbarButton
|
||||
icon={sourceMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
label={sourceMode ? 'Rich text editor' : 'Markdown source'}
|
||||
active={sourceMode}
|
||||
onClick={onToggleSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
import { fetchFreshEvents } from '@/lib/fetchFreshEvent';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
BLOBBONAUT_PROFILE_KINDS,
|
||||
@@ -76,7 +77,7 @@ export function useBlobbonautProfile() {
|
||||
// Main query to fetch the profile from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbonaut-profile', user?.pubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
queryFn: async () => {
|
||||
if (!user?.pubkey) {
|
||||
return null;
|
||||
}
|
||||
@@ -84,14 +85,18 @@ export function useBlobbonautProfile() {
|
||||
// Query with all possible d-tag values (canonical + legacy)
|
||||
const dValues = getBlobbonautQueryDValues(user.pubkey);
|
||||
|
||||
// Query BOTH current (11125) and legacy (31125) kinds for migration support
|
||||
const filter = {
|
||||
kinds: [...BLOBBONAUT_PROFILE_KINDS],
|
||||
authors: [user.pubkey],
|
||||
'#d': dValues,
|
||||
};
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
// Query BOTH current (11125) and legacy (31125) kinds for migration support.
|
||||
// Use a relaxed eoseTimeout (1000ms) so slower relays have time to respond
|
||||
// and we get the freshest profile across all relays.
|
||||
const events = await fetchFreshEvents(
|
||||
nostr,
|
||||
[{
|
||||
kinds: [...BLOBBONAUT_PROFILE_KINDS],
|
||||
authors: [user.pubkey],
|
||||
'#d': dValues,
|
||||
}],
|
||||
{ eoseTimeout: 1000 },
|
||||
);
|
||||
|
||||
// Filter to valid events
|
||||
const validEvents = events.filter(isValidBlobbonautEvent);
|
||||
|
||||
@@ -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(', ')}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { type ArticleFields } from '@/lib/articleHelpers';
|
||||
|
||||
/** Kind 31234 — NIP-37 Draft Wrap. */
|
||||
const DRAFT_WRAP_KIND = 31234;
|
||||
/** The inner draft kind we're wrapping. */
|
||||
const ARTICLE_KIND = 30023;
|
||||
|
||||
export interface Draft extends ArticleFields {
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
type DraftData = ArticleFields;
|
||||
|
||||
/** Build an unsigned kind-30023 event object from draft data. */
|
||||
function buildInnerDraftEvent(draft: DraftData): Record<string, unknown> {
|
||||
const tags: string[][] = [
|
||||
['d', draft.slug],
|
||||
['title', draft.title],
|
||||
];
|
||||
|
||||
if (draft.summary) tags.push(['summary', draft.summary]);
|
||||
if (draft.image) tags.push(['image', draft.image]);
|
||||
draft.tags.forEach(tag => tags.push(['t', tag]));
|
||||
|
||||
return {
|
||||
kind: ARTICLE_KIND,
|
||||
content: draft.content,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a decrypted inner draft event back into a Draft. */
|
||||
function parseDraftPayload(inner: Record<string, unknown>, wrapEvent: NostrEvent): Draft | null {
|
||||
const tags = (inner.tags ?? []) as string[][];
|
||||
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
|
||||
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
|
||||
return {
|
||||
id: wrapEvent.id,
|
||||
eventId: wrapEvent.id,
|
||||
title: getTag('title'),
|
||||
summary: getTag('summary'),
|
||||
content: (inner.content as string) || '',
|
||||
image: getTag('image'),
|
||||
tags: getTags('t'),
|
||||
slug: getTag('d'),
|
||||
updatedAt: wrapEvent.created_at * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDrafts() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Query and decrypt drafts from relay
|
||||
const query = useQuery<Draft[]>({
|
||||
queryKey: ['drafts', user?.pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !user.signer.nip44) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [DRAFT_WRAP_KIND], authors: [user.pubkey], '#k': [String(ARTICLE_KIND)], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
|
||||
const drafts: Draft[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
// Blank content means deleted
|
||||
if (!event.content.trim()) continue;
|
||||
|
||||
try {
|
||||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
|
||||
const inner = JSON.parse(decrypted) as Record<string, unknown>;
|
||||
const draft = parseDraftPayload(inner, event);
|
||||
if (draft && draft.content.trim()) drafts.push(draft);
|
||||
} catch {
|
||||
// Skip events that fail to decrypt or parse
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
},
|
||||
enabled: !!user?.pubkey && !!user?.signer.nip44,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
// Save draft: encrypt inner event and publish as kind 31234
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (draft: DraftData) => {
|
||||
if (!user?.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
|
||||
|
||||
const inner = buildInnerDraftEvent(draft);
|
||||
const plaintext = JSON.stringify(inner);
|
||||
const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext);
|
||||
|
||||
return publishEvent({
|
||||
kind: DRAFT_WRAP_KIND,
|
||||
content: encrypted,
|
||||
tags: [
|
||||
['d', draft.slug],
|
||||
['k', String(ARTICLE_KIND)],
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['drafts', user?.pubkey] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete draft (publish kind 5 deletion event)
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (slug: string) => {
|
||||
if (!user) throw new Error('User is not logged in');
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: 5,
|
||||
content: '',
|
||||
tags: [['a', `${DRAFT_WRAP_KIND}:${user.pubkey}:${slug}`]],
|
||||
});
|
||||
return { event, slug };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['drafts', user?.pubkey], (oldData: Draft[] | undefined) => {
|
||||
if (!oldData) return [];
|
||||
return oldData.filter(d => d.slug !== data?.slug);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
drafts: query.data || [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
saveDraft: saveMutation.mutateAsync,
|
||||
isSaving: saveMutation.isPending,
|
||||
deleteDraft: deleteMutation.mutateAsync,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { parseArticleEvent, type ArticleFields } from '@/lib/articleHelpers';
|
||||
|
||||
export interface PublishedArticle extends ArticleFields {
|
||||
id: string;
|
||||
eventId: string;
|
||||
publishedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function eventToArticle(event: NostrEvent): PublishedArticle {
|
||||
const parsed = parseArticleEvent(event);
|
||||
return {
|
||||
...parsed,
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
updatedAt: event.created_at * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePublishedArticles() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const query = useQuery<PublishedArticle[]>({
|
||||
queryKey: ['published-articles', user?.pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
|
||||
return events
|
||||
.filter(e => e.content.trim().length > 0)
|
||||
.map(eventToArticle)
|
||||
.sort((a, b) => b.publishedAt - a.publishedAt);
|
||||
},
|
||||
enabled: !!user?.pubkey,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
articles: query.data || [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
+158
@@ -494,3 +494,161 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Milkdown Editor Styles ─────────────────────────────────────────────────── */
|
||||
|
||||
.milkdown-editor .milkdown {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.milkdown-editor .editor {
|
||||
@apply outline-none min-h-[400px] p-3;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.milkdown-editor .ProseMirror {
|
||||
@apply outline-none min-h-[400px];
|
||||
}
|
||||
|
||||
.milkdown-editor .ProseMirror:focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.milkdown-editor h1 {
|
||||
@apply text-3xl font-bold mt-6 mb-4;
|
||||
}
|
||||
|
||||
.milkdown-editor h2 {
|
||||
@apply text-2xl font-semibold mt-5 mb-3;
|
||||
}
|
||||
|
||||
.milkdown-editor h3 {
|
||||
@apply text-xl font-semibold mt-4 mb-2;
|
||||
}
|
||||
|
||||
.milkdown-editor h4 {
|
||||
@apply text-lg font-medium mt-3 mb-2;
|
||||
}
|
||||
|
||||
/* Inline styles */
|
||||
.milkdown-editor strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
.milkdown-editor em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
.milkdown-editor del {
|
||||
@apply line-through text-muted-foreground;
|
||||
}
|
||||
|
||||
.milkdown-editor code {
|
||||
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
|
||||
}
|
||||
|
||||
/* Block elements */
|
||||
.milkdown-editor p {
|
||||
@apply my-1.5;
|
||||
}
|
||||
|
||||
.milkdown-editor blockquote {
|
||||
@apply border-l-4 border-primary/40 pl-4 my-4 italic text-muted-foreground;
|
||||
}
|
||||
|
||||
.milkdown-editor pre {
|
||||
@apply bg-muted rounded-lg p-4 my-4 overflow-x-auto;
|
||||
}
|
||||
|
||||
.milkdown-editor pre code {
|
||||
@apply bg-transparent p-0 font-mono;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.milkdown-editor ul {
|
||||
@apply list-disc list-inside my-3 space-y-1;
|
||||
}
|
||||
|
||||
.milkdown-editor ol {
|
||||
@apply list-decimal list-inside my-3 space-y-1;
|
||||
}
|
||||
|
||||
.milkdown-editor li {
|
||||
@apply pl-1;
|
||||
}
|
||||
|
||||
.milkdown-editor li p {
|
||||
@apply inline my-0;
|
||||
}
|
||||
|
||||
/* Task lists (GFM) */
|
||||
.milkdown-editor ul.task-list {
|
||||
@apply list-none pl-0;
|
||||
}
|
||||
|
||||
.milkdown-editor li.task-list-item {
|
||||
@apply flex items-start gap-2 pl-0;
|
||||
}
|
||||
|
||||
.milkdown-editor li.task-list-item input[type="checkbox"] {
|
||||
@apply mt-1.5 h-4 w-4 rounded border-border;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.milkdown-editor a {
|
||||
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.milkdown-editor hr {
|
||||
@apply my-6 border-border;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.milkdown-editor img {
|
||||
@apply max-w-full h-auto rounded-lg my-4;
|
||||
}
|
||||
|
||||
/* Tables (GFM) */
|
||||
.milkdown-editor table {
|
||||
@apply w-full border-collapse my-4;
|
||||
}
|
||||
|
||||
.milkdown-editor th,
|
||||
.milkdown-editor td {
|
||||
@apply border border-border px-3 py-2 text-left;
|
||||
}
|
||||
|
||||
.milkdown-editor th {
|
||||
@apply bg-muted font-semibold;
|
||||
}
|
||||
|
||||
.milkdown-editor tr:nth-child(even) {
|
||||
@apply bg-muted/30;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Upload placeholder */
|
||||
.milkdown-upload-placeholder {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
.milkdown-upload-placeholder::before {
|
||||
content: '';
|
||||
@apply w-4 h-4 border-2 border-muted-foreground/30 border-t-primary rounded-full animate-spin;
|
||||
}
|
||||
|
||||
/* Placeholder — only when unfocused AND content is empty. */
|
||||
.milkdown-editor .ProseMirror:not(:focus):not(.has-content) > p:first-child::before {
|
||||
@apply text-muted-foreground pointer-events-none float-left h-0;
|
||||
content: var(--ph, '');
|
||||
}
|
||||
|
||||
/* Milkdown content area */
|
||||
.milkdown-editor .milkdown-content .ProseMirror {
|
||||
@apply min-h-[350px];
|
||||
}
|
||||
|
||||
|
||||
@@ -566,7 +566,7 @@ export class NostrBatcher {
|
||||
|
||||
req(
|
||||
filters: NostrFilter[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: { signal?: AbortSignal; relays?: string[]; eoseTimeout?: number },
|
||||
): AsyncIterable<import('@nostrify/types').NostrRelayEVENT | import('@nostrify/types').NostrRelayEOSE | import('@nostrify/types').NostrRelayCLOSED> {
|
||||
return this.pool.req(filters, opts);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** Fields shared by drafts and published articles. */
|
||||
export interface ArticleFields {
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract common article fields from a Nostr event's tags + content.
|
||||
* Works for kind 30023 (published) events and the inner event of NIP-37 draft wraps.
|
||||
*/
|
||||
export function parseArticleEvent(event: NostrEvent): ArticleFields & { publishedAt: number } {
|
||||
const getTag = (name: string) => event.tags.find(t => t[0] === name)?.[1] || '';
|
||||
const getTags = (name: string) => event.tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
|
||||
const publishedAtTag = getTag('published_at');
|
||||
const publishedAt = publishedAtTag ? parseInt(publishedAtTag) * 1000 : event.created_at * 1000;
|
||||
|
||||
return {
|
||||
title: getTag('title'),
|
||||
summary: getTag('summary'),
|
||||
content: event.content,
|
||||
image: getTag('image'),
|
||||
tags: getTags('t'),
|
||||
slug: getTag('d'),
|
||||
publishedAt,
|
||||
};
|
||||
}
|
||||
@@ -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
+12
-4
@@ -11,6 +11,14 @@ interface ChangelogEntry {
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Apply basic typographic transformations to a changelog item string. */
|
||||
function prettify(text: string): string {
|
||||
return text
|
||||
.replace(/ -- /g, ' \u2014 ') // space-dash-dash-space → em dash
|
||||
.replace(/(\w)--(\w)/g, '$1\u2013$2') // word--word → en dash
|
||||
.replace(/ (\S+)$/, '\u00A0$1'); // prevent orphaned last word
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Keep a Changelog formatted markdown string into structured data.
|
||||
* @see https://keepachangelog.com/
|
||||
@@ -43,10 +51,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
if (itemMatch && current) {
|
||||
const section = current.sections[current.sections.length - 1];
|
||||
if (section) {
|
||||
section.items.push(itemMatch[1]);
|
||||
section.items.push(prettify(itemMatch[1]));
|
||||
} else {
|
||||
// Item without a category heading — treat as "Changed"
|
||||
current.sections.push({ category: 'Changed', items: [itemMatch[1]] });
|
||||
current.sections.push({ category: 'Changed', items: [prettify(itemMatch[1])] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -58,10 +66,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const section = current.sections[current.sections.length - 1];
|
||||
if (section) {
|
||||
// Append to last item or add new item
|
||||
section.items.push(trimmed);
|
||||
section.items.push(prettify(trimmed));
|
||||
} else {
|
||||
// Freeform text under version with no category — store in a generic section
|
||||
current.sections.push({ category: 'Changed', items: [trimmed] });
|
||||
current.sections.push({ category: 'Changed', items: [prettify(trimmed)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +128,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
route: 'articles',
|
||||
addressable: true,
|
||||
section: 'feed',
|
||||
blurb: 'Blog posts, essays, and guides. Write and publish from a dedicated editor.',
|
||||
sites: [{ url: 'https://inkwell.shakespeare.wtf' }],
|
||||
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
|
||||
},
|
||||
// Media
|
||||
{
|
||||
@@ -479,7 +478,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
id: 'development',
|
||||
showKey: 'showDevelopment',
|
||||
feedKey: 'feedIncludeDevelopment',
|
||||
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267],
|
||||
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 30063, 31990],
|
||||
label: 'Development',
|
||||
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
|
||||
route: 'development',
|
||||
@@ -547,8 +546,10 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
|
||||
35128: 'nsite',
|
||||
30008: 'profile badges',
|
||||
30817: 'repository issue',
|
||||
32267: 'app',
|
||||
30063: 'release',
|
||||
32267: 'Zapstore app',
|
||||
31990: 'app',
|
||||
30063: 'Zapstore release',
|
||||
3063: 'Zapstore asset',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { NostrEvent, NostrFilter, NPool } from '@nostrify/nostrify';
|
||||
|
||||
interface FetchFreshEventOpts {
|
||||
/**
|
||||
* Override the pool-level eoseTimeout for this query. When set, uses
|
||||
* `nostr.req()` directly with this value instead of `nostr.query()`,
|
||||
* giving slower relays more time to respond.
|
||||
*
|
||||
* The default pool eoseTimeout is 300ms (resolves quickly after the
|
||||
* fastest relay). Set to eg. 1000 for accuracy-sensitive queries where
|
||||
* you need the absolute freshest event across all relays.
|
||||
*/
|
||||
eoseTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the freshest version of a replaceable/addressable event directly from
|
||||
* relays, bypassing any local cache.
|
||||
@@ -24,13 +37,9 @@ import type { NostrEvent, NostrFilter, NPool } from '@nostrify/nostrify';
|
||||
export async function fetchFreshEvent(
|
||||
nostr: NPool,
|
||||
filter: NostrFilter,
|
||||
opts?: FetchFreshEventOpts,
|
||||
): Promise<NostrEvent | null> {
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ ...filter, limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
const events = await fetchFreshEvents(nostr, [{ ...filter, limit: 1 }], opts);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
@@ -39,3 +48,42 @@ export async function fetchFreshEvent(
|
||||
current.created_at > latest.created_at ? current : latest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches events from relays, bypassing any local cache. Like
|
||||
* {@link fetchFreshEvent} but accepts multiple filters and returns all
|
||||
* matching events (not just one).
|
||||
*
|
||||
* When `opts.eoseTimeout` is set, uses `nostr.req()` directly with that
|
||||
* timeout, overriding the pool-level eoseTimeout. Otherwise falls back to
|
||||
* the standard `nostr.query()` path.
|
||||
*/
|
||||
export async function fetchFreshEvents(
|
||||
nostr: NPool,
|
||||
filters: NostrFilter[],
|
||||
opts?: FetchFreshEventOpts,
|
||||
): Promise<NostrEvent[]> {
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
|
||||
if (opts?.eoseTimeout !== undefined) {
|
||||
// Use req() directly so we can pass a custom eoseTimeout,
|
||||
// overriding the pool-level value (typically 300ms).
|
||||
const events: NostrEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for await (const msg of nostr.req(filters, { signal, eoseTimeout: opts.eoseTimeout })) {
|
||||
if (msg[0] === 'EOSE' || msg[0] === 'CLOSED') break;
|
||||
if (msg[0] === 'EVENT') {
|
||||
const event = msg[2];
|
||||
if (!seen.has(event.id)) {
|
||||
seen.add(event.id);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
return nostr.query(filters, { signal });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Draft } from '@/hooks/useDrafts';
|
||||
|
||||
const DRAFTS_KEY = 'article-drafts';
|
||||
|
||||
/** Save a draft to localStorage. Returns the draft ID or null on failure. */
|
||||
export function saveDraft(draft: Omit<Draft, 'id' | 'updatedAt'> & { id?: string }): string | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
const drafts: Draft[] = stored ? JSON.parse(stored) : [];
|
||||
|
||||
const existingIndex = draft.id
|
||||
? drafts.findIndex(d => d.id === draft.id)
|
||||
: drafts.findIndex(d => d.slug === draft.slug);
|
||||
|
||||
const newDraft: Draft = {
|
||||
...draft,
|
||||
id: draft.id || (existingIndex >= 0 ? drafts[existingIndex].id : crypto.randomUUID()),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
drafts[existingIndex] = newDraft;
|
||||
} else {
|
||||
drafts.unshift(newDraft);
|
||||
}
|
||||
|
||||
// Keep only the 20 most recent drafts
|
||||
const trimmedDrafts = drafts.slice(0, 20);
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(trimmedDrafts));
|
||||
|
||||
return newDraft.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a draft by slug from localStorage. */
|
||||
export function deleteDraftBySlug(slug: string): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
if (!stored) return;
|
||||
|
||||
const drafts: Draft[] = JSON.parse(stored);
|
||||
const filtered = drafts.filter(d => d.slug !== slug);
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete draft:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a draft by id from localStorage. Returns the remaining drafts. */
|
||||
export function deleteLocalDraftById(id: string): Draft[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const drafts: Draft[] = JSON.parse(stored);
|
||||
const filtered = drafts.filter(d => d.id !== id);
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
|
||||
return filtered;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete draft:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all local drafts. */
|
||||
export function getLocalDrafts(): Draft[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */
|
||||
export 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the NIP-5A canonical subdomain for an nsite event.
|
||||
*
|
||||
* - Root site (kind 15128): `<npub>`
|
||||
* - Named site (kind 35128 with d-tag): `<pubkeyB36><dTag>`
|
||||
*/
|
||||
export function getNsiteSubdomain(event: NostrEvent): string {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
|
||||
if (event.kind === 35128 && dTag) {
|
||||
const pubkeyB36 = hexToBase36(event.pubkey);
|
||||
return `${pubkeyB36}${dTag}`;
|
||||
}
|
||||
|
||||
return nip19.npubEncode(event.pubkey);
|
||||
}
|
||||
+6
-12
@@ -8,11 +8,6 @@ import type { CoreThemeColors, ThemeConfig, ThemesConfig } from '@/themes';
|
||||
/** Zod schema for Theme validation */
|
||||
export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfies z.ZodType<Theme>;
|
||||
|
||||
/**
|
||||
* Accepts current theme values as well as legacy values ("black", "pink")
|
||||
* from older configs. Consumers should migrate legacy values to "custom".
|
||||
*/
|
||||
export const ThemeSchemaCompat = z.enum(['dark', 'light', 'system', 'custom', 'black', 'pink']);
|
||||
|
||||
/** HSL value string like "258 70% 55%" */
|
||||
const HslValue = z.string().regex(/^\d/);
|
||||
@@ -208,19 +203,18 @@ export const SavedFeedSchema = z.object({
|
||||
/**
|
||||
* Zod schema for the full AppConfig stored in localStorage.
|
||||
*
|
||||
* Uses compat sub-schemas (ThemeSchemaCompat, ThemeConfigCompatSchema) so
|
||||
* legacy values parse successfully. Migration from legacy theme values
|
||||
* ("black", "pink") to "custom" + customTheme is handled downstream by
|
||||
* the AppProvider deserializer.
|
||||
* Uses ThemeConfigCompatSchema for the customTheme field so legacy
|
||||
* 19-token color objects still parse successfully.
|
||||
*/
|
||||
export const AppConfigSchema = z.object({
|
||||
appName: z.string().optional(),
|
||||
appId: z.string().optional(),
|
||||
homePage: z.string().optional(),
|
||||
clientName: z.string().optional(),
|
||||
client: z.string().optional(),
|
||||
/** NIP-19 naddr1 string for the kind 31990 handler event. */
|
||||
client: z.string().startsWith('naddr1').optional(),
|
||||
magicMouse: z.boolean().optional(),
|
||||
theme: ThemeSchemaCompat,
|
||||
theme: ThemeSchema,
|
||||
customTheme: ThemeConfigCompatSchema.optional(),
|
||||
autoShareTheme: z.boolean(),
|
||||
themes: ThemesConfigSchema.optional(),
|
||||
@@ -298,7 +292,7 @@ export const ContentFilterSchema = z.object({
|
||||
* Uses looseObject to preserve unknown keys from newer app versions.
|
||||
*/
|
||||
export const EncryptedSettingsSchema = z.looseObject({
|
||||
theme: ThemeSchemaCompat.optional(),
|
||||
theme: ThemeSchema.optional(),
|
||||
customTheme: ThemeConfigCompatSchema.optional(),
|
||||
autoShareTheme: z.boolean().optional(),
|
||||
useAppRelays: z.boolean().optional(),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Egg,
|
||||
Repeat2,
|
||||
Scroll,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Smile,
|
||||
@@ -133,6 +134,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
requiresAuth: true,
|
||||
},
|
||||
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
|
||||
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
|
||||
{
|
||||
id: "letters",
|
||||
label: "Letters",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useParams } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { AddressPointer } from 'nostr-tools/nip19';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { getLocalDrafts } from '@/lib/localDrafts';
|
||||
import { parseArticleEvent } from '@/lib/articleHelpers';
|
||||
|
||||
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
|
||||
export function ArticleEditorPage() {
|
||||
useLayoutOptions({ showFAB: false, hasSubHeader: true });
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const { naddr: naddrParam } = useParams<{ naddr: string }>();
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const draftSlug = searchParams.get('draft');
|
||||
|
||||
const [initialData, setInitialData] = useState<(Partial<ArticleData> & { publishedAt?: number }) | undefined>(undefined);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
|
||||
|
||||
// Load draft from relay (NIP-37 kind 31234, encrypted) or localStorage if ?draft=<slug>
|
||||
useEffect(() => {
|
||||
if (!draftSlug) return;
|
||||
|
||||
const loadDraft = async () => {
|
||||
if (user?.signer.nip44) {
|
||||
try {
|
||||
const events = await nostr.query([
|
||||
{ kinds: [31234], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
|
||||
]);
|
||||
if (events.length > 0 && events[0].content.trim()) {
|
||||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
|
||||
const inner = JSON.parse(decrypted) as Record<string, unknown>;
|
||||
const tags = (inner.tags ?? []) as string[][];
|
||||
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
|
||||
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
setInitialData({
|
||||
title: getTag('title'),
|
||||
summary: getTag('summary'),
|
||||
content: (inner.content as string) || '',
|
||||
image: getTag('image'),
|
||||
tags: getTags('t'),
|
||||
slug: getTag('d'),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to localStorage
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
const drafts = getLocalDrafts();
|
||||
const draft = drafts.find((d) => d.slug === draftSlug);
|
||||
if (draft) {
|
||||
setInitialData({
|
||||
title: draft.title,
|
||||
summary: draft.summary,
|
||||
content: draft.content,
|
||||
image: draft.image,
|
||||
tags: draft.tags,
|
||||
slug: draft.slug,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadDraft();
|
||||
}, [draftSlug, user, nostr]);
|
||||
|
||||
// Load existing article for editing if /articles/edit/:naddr
|
||||
useEffect(() => {
|
||||
if (!naddrParam) return;
|
||||
|
||||
let decoded: { type: string; data: AddressPointer };
|
||||
try {
|
||||
decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
|
||||
if (decoded.type !== 'naddr') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const addr = decoded.data;
|
||||
|
||||
// Only allow editing your own articles
|
||||
if (user && addr.pubkey !== user.pubkey) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
nostr
|
||||
.query([
|
||||
{
|
||||
kinds: [addr.kind],
|
||||
authors: [addr.pubkey],
|
||||
'#d': [addr.identifier],
|
||||
limit: 1,
|
||||
},
|
||||
])
|
||||
.then((events) => {
|
||||
if (events.length > 0) {
|
||||
setInitialData(parseArticleEvent(events[0]));
|
||||
setEditMode(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load article for editing:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [naddrParam, nostr, user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ArticleEditor initialData={initialData} editMode={editMode} />;
|
||||
}
|
||||
+410
-116
@@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Egg, Moon, Sun, Loader2, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink } from 'lucide-react';
|
||||
import { Egg, Moon, Sun, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink, Settings2 } from 'lucide-react';
|
||||
// Note: Sparkles kept for BlobbiBottomBar center action button
|
||||
// Note: Plus kept for AdoptAnotherBlobbiCard
|
||||
// Note: AlertTriangle kept for stat warning indicators
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
type SelectedTrack,
|
||||
type BlobbiReactionState,
|
||||
type StartIncubationMode,
|
||||
useDailyMissions,
|
||||
} from '@/blobbi/actions';
|
||||
import { BlobbiOnboardingFlow } from '@/blobbi/onboarding';
|
||||
import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/companion/interaction';
|
||||
@@ -88,6 +89,23 @@ import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
|
||||
import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
import { MissionSurfaceCard } from '@/blobbi/ui/components/MissionSurfaceCard';
|
||||
import { ActionBarEditor } from '@/blobbi/ui/components/ActionBarEditor';
|
||||
import {
|
||||
type ActionBarPreferences,
|
||||
type BarItemId,
|
||||
getVisibleSlots,
|
||||
getVisibleBarIds,
|
||||
loadPreferences,
|
||||
savePreferences,
|
||||
loadMissionCardVisible,
|
||||
saveMissionCardVisible,
|
||||
} from '@/blobbi/ui/lib/action-bar-preferences';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useFirstHatchTour, useFirstHatchTourActivation, FirstHatchTourCard } from '@/blobbi/tour';
|
||||
import { buildHatchPhrase, isValidHatchPost } from '@/blobbi/actions';
|
||||
import type { EggTourVisualState } from '@/blobbi/egg';
|
||||
|
||||
/**
|
||||
* Get the localStorage key for the selected Blobbi.
|
||||
@@ -871,6 +889,24 @@ function BlobbiDashboard({
|
||||
const [showMissionsModal, setShowMissionsModal] = useState(false);
|
||||
const [showShopModal, setShowShopModal] = useState(false);
|
||||
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
||||
const [showBarEditor, setShowBarEditor] = useState(false);
|
||||
|
||||
// ─── Action Bar Preferences ───
|
||||
const [barPrefs, setBarPrefs] = useState<ActionBarPreferences>(loadPreferences);
|
||||
const handleBarPrefsUpdate = useCallback((prefs: ActionBarPreferences) => {
|
||||
setBarPrefs(prefs);
|
||||
savePreferences(prefs);
|
||||
}, []);
|
||||
|
||||
// ─── Mission Surface Card Visibility ───
|
||||
const [missionCardVisible, setMissionCardVisible] = useState(loadMissionCardVisible);
|
||||
const handleToggleMissionCard = useCallback((visible: boolean) => {
|
||||
setMissionCardVisible(visible);
|
||||
saveMissionCardVisible(visible);
|
||||
}, []);
|
||||
|
||||
// ─── Daily Missions (for surface card) ───
|
||||
const dailyMissions = useDailyMissions({ availableStages });
|
||||
|
||||
// DEV ONLY: Emotion panel state
|
||||
const [showEmotionPanel, setShowEmotionPanel] = useState(false);
|
||||
@@ -934,6 +970,168 @@ function BlobbiDashboard({
|
||||
const [showIncubationDialog, setShowIncubationDialog] = useState(false);
|
||||
const [showEvolutionDialog, setShowEvolutionDialog] = useState(false);
|
||||
|
||||
// ─── First Hatch Tour ───
|
||||
const firstHatchTour = useFirstHatchTour();
|
||||
useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading: false, // companions are already loaded at this point
|
||||
tour: firstHatchTour,
|
||||
profileOnboardingDone: profile?.onboardingDone,
|
||||
});
|
||||
const isFirstHatchTourActive = firstHatchTour.state.isActive;
|
||||
|
||||
// The required phrase for the first-hatch post
|
||||
const firstHatchPhrase = useMemo(() => buildHatchPhrase(companion.name), [companion.name]);
|
||||
|
||||
// Auto-advance from idle -> show_hatch_card (immediately)
|
||||
useEffect(() => {
|
||||
if (!isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('idle')) {
|
||||
firstHatchTour.actions.advance(); // -> show_hatch_card
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// Show the inline first-hatch card for all pre-hatch steps
|
||||
const showFirstHatchCard = isFirstHatchTourActive && firstHatchTour.isAnyStep(
|
||||
'show_hatch_card', 'egg_glowing_waiting_click',
|
||||
'egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3',
|
||||
);
|
||||
|
||||
// Detect hatch post completion for the first-hatch tour
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const tourAwaitingPost = isFirstHatchTourActive && firstHatchTour.isStep('show_hatch_card');
|
||||
|
||||
const { data: tourPostFound } = useQuery({
|
||||
queryKey: ['first-hatch-tour-post', user?.pubkey, companion.name],
|
||||
queryFn: async () => {
|
||||
if (!user?.pubkey) return false;
|
||||
const events = await nostr.query([{
|
||||
kinds: [1],
|
||||
authors: [user.pubkey],
|
||||
limit: 20,
|
||||
}]);
|
||||
return events.some(e => isValidHatchPost(e, companion.name));
|
||||
},
|
||||
enabled: tourAwaitingPost && !!user?.pubkey,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 3000,
|
||||
});
|
||||
|
||||
// When the post is found during show_hatch_card, show the completed state
|
||||
// for 2 seconds so the user sees the checkmark, then auto-advance to glowing.
|
||||
useEffect(() => {
|
||||
if (!tourPostFound || !isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('show_hatch_card')) {
|
||||
const timer = setTimeout(() => {
|
||||
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [tourPostFound, isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// Fake pointer hint: after 10s on glowing_waiting_click, show hint; repeat every 5s
|
||||
const [showClickHint, setShowClickHint] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isFirstHatchTourActive || !firstHatchTour.isStep('egg_glowing_waiting_click')) {
|
||||
setShowClickHint(false);
|
||||
return;
|
||||
}
|
||||
const initial = setTimeout(() => setShowClickHint(true), 10000);
|
||||
const repeat = setInterval(() => setShowClickHint(true), 5000);
|
||||
return () => { clearTimeout(initial); clearInterval(repeat); };
|
||||
}, [isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// Handle egg click during the tour (advance crack stages)
|
||||
const handleTourEggClick = useCallback(() => {
|
||||
if (!isFirstHatchTourActive) return;
|
||||
setShowClickHint(false);
|
||||
if (firstHatchTour.isStep('egg_glowing_waiting_click')) {
|
||||
firstHatchTour.actions.advance(); // -> egg_crack_stage_1
|
||||
} else if (firstHatchTour.isStep('egg_crack_stage_1')) {
|
||||
firstHatchTour.actions.advance(); // -> egg_crack_stage_2
|
||||
} else if (firstHatchTour.isStep('egg_crack_stage_2')) {
|
||||
firstHatchTour.actions.advance(); // -> egg_crack_stage_3
|
||||
} else if (firstHatchTour.isStep('egg_crack_stage_3')) {
|
||||
firstHatchTour.actions.advance(); // -> egg_opening
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
// Auto-advance for opening -> hatching -> complete (with hatch mutation)
|
||||
useEffect(() => {
|
||||
if (!isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('egg_opening')) {
|
||||
const timer = setTimeout(() => {
|
||||
firstHatchTour.actions.advance(); // -> egg_hatching
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFirstHatchTourActive) return;
|
||||
if (firstHatchTour.isStep('egg_hatching')) {
|
||||
// Execute the actual hatch mutation, mark onboarding complete on the
|
||||
// profile event, then complete the tour's local state.
|
||||
const doHatch = async () => {
|
||||
try {
|
||||
await onHatch();
|
||||
|
||||
// Persist blobbi_onboarding_done to the Blobbonaut profile (authoritative)
|
||||
if (profile) {
|
||||
try {
|
||||
const updatedTags = updateBlobbonautTags(profile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
});
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(event);
|
||||
} catch (e) {
|
||||
console.error('[FirstHatchTour] Failed to persist onboarding completion to profile:', e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
firstHatchTour.actions.complete();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(doHatch, 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour, onHatch, profile, publishEvent, updateProfileEvent]);
|
||||
|
||||
// Derive tourVisualState for the egg visual
|
||||
const tourVisualState = useMemo((): EggTourVisualState => {
|
||||
if (!isFirstHatchTourActive) return 'idle';
|
||||
const step = firstHatchTour.state.currentStepId;
|
||||
switch (step) {
|
||||
case 'show_hatch_card': return 'show_hatch_card';
|
||||
case 'egg_glowing_waiting_click': return 'glowing_waiting_click';
|
||||
case 'egg_crack_stage_1': return 'crack_stage_1';
|
||||
case 'egg_crack_stage_2': return 'crack_stage_2';
|
||||
case 'egg_crack_stage_3': return 'crack_stage_3';
|
||||
case 'egg_opening': return 'opening';
|
||||
case 'egg_hatching': return 'hatching';
|
||||
default: return 'idle';
|
||||
}
|
||||
}, [isFirstHatchTourActive, firstHatchTour.state.currentStepId]);
|
||||
|
||||
// DEV ONLY: Build tour dev actions for the state editor
|
||||
const tourDevActions = useMemo(() => ({
|
||||
skipPostRequirement: () => {
|
||||
if (firstHatchTour.isStep('show_hatch_card')) {
|
||||
firstHatchTour.actions.goTo('egg_glowing_waiting_click');
|
||||
}
|
||||
},
|
||||
resetTour: () => {
|
||||
firstHatchTour.actions.reset();
|
||||
},
|
||||
currentStepId: firstHatchTour.state.currentStepId,
|
||||
isCompleted: firstHatchTour.state.isCompleted,
|
||||
}), [firstHatchTour]);
|
||||
|
||||
// State detection for tasks
|
||||
// Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state
|
||||
const isIncubating = companion.state === 'incubating';
|
||||
@@ -1348,8 +1546,6 @@ function BlobbiDashboard({
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 py-4 sm:px-6">
|
||||
{/* Floating Dashboard Controls */}
|
||||
<BlobbiDashboardFloatingControls />
|
||||
|
||||
{/* Blobbi Name */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
@@ -1363,7 +1559,6 @@ function BlobbiDashboard({
|
||||
|
||||
{/* Main Blobbi Visual */}
|
||||
{isActiveFloatingCompanion ? (
|
||||
// Show message when Blobbi is active as floating companion
|
||||
<div className="flex flex-col items-center justify-center size-48 sm:size-56 text-center">
|
||||
<Footprints className="size-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -1372,7 +1567,6 @@ function BlobbiDashboard({
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative transition-all duration-500">
|
||||
{/* Subtle glow effect behind the egg */}
|
||||
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
|
||||
|
||||
<BlobbiStageVisual
|
||||
@@ -1383,15 +1577,34 @@ function BlobbiDashboard({
|
||||
recipe={hasDevOverride ? undefined : statusRecipe}
|
||||
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
|
||||
emotion={effectiveEmotion}
|
||||
|
||||
tourVisualState={tourVisualState}
|
||||
onTourEggClick={handleTourEggClick}
|
||||
className="size-48 sm:size-56"
|
||||
/>
|
||||
{showClickHint && firstHatchTour.isStep('egg_glowing_waiting_click') && (
|
||||
<div className="absolute bottom-14 inset-x-0 flex items-center justify-center pointer-events-none select-none">
|
||||
<span className="text-4xl animate-bounce drop-shadow-lg">👆</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
{/* First Hatch Tour: inline card directly below egg (above stats) */}
|
||||
{showFirstHatchCard && (
|
||||
<div className="px-4 sm:px-6 mt-2">
|
||||
<FirstHatchTourCard
|
||||
blobbiName={companion.name}
|
||||
requiredPhrase={firstHatchPhrase}
|
||||
postCompleted={!!tourPostFound || !firstHatchTour.isStep('show_hatch_card')}
|
||||
onCreatePost={() => setShowPostModal(true)}
|
||||
currentStep={firstHatchTour.state.currentStepId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Section - hidden during first-hatch tour */}
|
||||
{!isFirstHatchTourActive && (
|
||||
<div className="px-4 sm:px-6">
|
||||
{/* Stats Grid - shows projected decay state */}
|
||||
{/* Only stats below the visibility threshold are shown (centralized in getVisibleStatsWithValues) */}
|
||||
@@ -1421,9 +1634,7 @@ function BlobbiDashboard({
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
|
||||
{/* Inline Activity Area - inside padded container for proper spacing above bottom bar */}
|
||||
{/* Inline Activity Area */}
|
||||
{inlineActivity.type === 'music' && (
|
||||
<div className="mt-6">
|
||||
<InlineMusicPlayer
|
||||
@@ -1450,39 +1661,66 @@ function BlobbiDashboard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Action Bar */}
|
||||
<BlobbiBottomBar
|
||||
onBlobbiesClick={() => setShowSelector(true)}
|
||||
onMissionsClick={() => setShowMissionsModal(true)}
|
||||
onActionsClick={() => setShowActionsModal(true)}
|
||||
onShopClick={() => setShowShopModal(true)}
|
||||
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
|
||||
isInTaskProcess={isInTaskProcess}
|
||||
remainingTasksCount={remainingTasksCount}
|
||||
allTasksComplete={allTasksComplete}
|
||||
stage={companion.stage}
|
||||
blobbiNaddr={blobbiNaddr}
|
||||
onSetAsCompanion={handleSetAsCompanion}
|
||||
isCurrentCompanion={isCurrentCompanion}
|
||||
isUpdatingCompanion={isUpdatingCompanion}
|
||||
onTakePhoto={() => setShowPhotoModal(true)}
|
||||
onEvolve={
|
||||
canStartIncubation
|
||||
? () => setShowIncubationDialog(true)
|
||||
: canStartEvolution
|
||||
? () => setShowEvolutionDialog(true)
|
||||
: isEgg
|
||||
? onHatch
|
||||
: onEvolve
|
||||
}
|
||||
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
|
||||
hideEvolveButton={isIncubating || isEvolvingState}
|
||||
isIncubationAction={canStartIncubation}
|
||||
isEvolutionAction={canStartEvolution}
|
||||
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
|
||||
onDevOpenEditor={() => setShowDevEditor(true)}
|
||||
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
|
||||
{/* Mission Surface Card - hidden during first-hatch tour or when user dismissed */}
|
||||
{!isFirstHatchTourActive && missionCardVisible && (
|
||||
<div className="px-4 sm:px-6 mt-3">
|
||||
<MissionSurfaceCard
|
||||
tasks={taskProcess.tasks}
|
||||
isInTaskProcess={taskProcess.config.isActive}
|
||||
processType={taskProcess.config.type}
|
||||
dailyMissions={dailyMissions.missions}
|
||||
onViewAll={() => setShowMissionsModal(true)}
|
||||
onHide={() => handleToggleMissionCard(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Action Bar - hidden during first-hatch tour */}
|
||||
{!isFirstHatchTourActive && (
|
||||
<BlobbiBottomBar
|
||||
onBlobbiesClick={() => setShowSelector(true)}
|
||||
onMissionsClick={() => setShowMissionsModal(true)}
|
||||
onActionsClick={() => setShowActionsModal(true)}
|
||||
onShopClick={() => setShowShopModal(true)}
|
||||
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
|
||||
isInTaskProcess={isInTaskProcess}
|
||||
remainingTasksCount={remainingTasksCount}
|
||||
allTasksComplete={allTasksComplete}
|
||||
stage={companion.stage}
|
||||
blobbiNaddr={blobbiNaddr}
|
||||
onSetAsCompanion={handleSetAsCompanion}
|
||||
isCurrentCompanion={isCurrentCompanion}
|
||||
isUpdatingCompanion={isUpdatingCompanion}
|
||||
onTakePhoto={() => setShowPhotoModal(true)}
|
||||
onEvolve={
|
||||
canStartIncubation
|
||||
? () => setShowIncubationDialog(true)
|
||||
: canStartEvolution
|
||||
? () => setShowEvolutionDialog(true)
|
||||
: isEgg
|
||||
? onHatch
|
||||
: onEvolve
|
||||
}
|
||||
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
|
||||
hideEvolveButton={isIncubating || isEvolvingState || isFirstHatchTourActive}
|
||||
isIncubationAction={canStartIncubation}
|
||||
isEvolutionAction={canStartEvolution}
|
||||
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
|
||||
onDevOpenEditor={() => setShowDevEditor(true)}
|
||||
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
|
||||
barPreferences={barPrefs}
|
||||
onEditBar={() => setShowBarEditor(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Bar Editor */}
|
||||
<ActionBarEditor
|
||||
open={showBarEditor}
|
||||
onOpenChange={setShowBarEditor}
|
||||
preferences={barPrefs}
|
||||
onUpdate={handleBarPrefsUpdate}
|
||||
/>
|
||||
|
||||
{/* Blobbi Selector Modal */}
|
||||
@@ -1596,6 +1834,8 @@ function BlobbiDashboard({
|
||||
onStopEvolution={handleStopEvolution}
|
||||
isStoppingEvolution={isStoppingEvolution}
|
||||
availableStages={availableStages}
|
||||
showMissionCard={missionCardVisible}
|
||||
onToggleMissionCard={handleToggleMissionCard}
|
||||
/>
|
||||
|
||||
{/* Shop & Inventory Modal (unified) */}
|
||||
@@ -1617,6 +1857,7 @@ function BlobbiDashboard({
|
||||
onSuccess={refetchCurrentTasks}
|
||||
/>
|
||||
|
||||
|
||||
{/* Blobbi Photo Modal - polaroid-style photo capture */}
|
||||
<BlobbiPhotoModal
|
||||
open={showPhotoModal}
|
||||
@@ -1651,6 +1892,7 @@ function BlobbiDashboard({
|
||||
companion={companion}
|
||||
onApply={onDevEditorApply}
|
||||
isUpdating={isDevUpdating}
|
||||
tourDevActions={tourDevActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1665,39 +1907,6 @@ function BlobbiDashboard({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Quick Action Button ──────────────────────────────────────────────────────
|
||||
|
||||
interface QuickActionButtonProps {
|
||||
children: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function QuickActionButton({ children, tooltip, onClick, disabled, loading }: QuickActionButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="size-10 rounded-full bg-background/80 backdrop-blur-sm border-border hover:bg-accent hover:border-border transition-all shadow-sm"
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dashboard Floating Controls ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the appropriate tooltip for the evolve/hatch button based on stage and action state.
|
||||
*/
|
||||
@@ -1715,18 +1924,6 @@ function getEvolveTooltip(
|
||||
return 'Evolve';
|
||||
}
|
||||
|
||||
/** Floating back button for the Blobbi dashboard. */
|
||||
function BlobbiDashboardFloatingControls({ onBack }: { onBack?: () => void }) {
|
||||
if (!onBack) return null;
|
||||
return (
|
||||
<div className="absolute top-28 sm:top-32 left-4 sm:left-6 flex flex-col gap-2 z-20">
|
||||
<QuickActionButton tooltip="Go Back" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</QuickActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
interface StatIndicatorProps {
|
||||
@@ -2065,12 +2262,18 @@ interface BlobbiBottomBarProps {
|
||||
hideEvolveButton?: boolean;
|
||||
isIncubationAction?: boolean;
|
||||
isEvolutionAction?: boolean;
|
||||
// ── Action bar preferences ──
|
||||
barPreferences: ActionBarPreferences;
|
||||
onEditBar: () => void;
|
||||
// ── Dev-only actions ──
|
||||
onDevInstantTransition?: () => void;
|
||||
onDevOpenEditor?: () => void;
|
||||
onDevOpenEmotionPanel?: () => void;
|
||||
}
|
||||
|
||||
/** Handler map keyed by BarItemId so the bar can generically call the right action */
|
||||
type BarItemHandlers = Record<BarItemId, () => void>;
|
||||
|
||||
function BlobbiBottomBar({
|
||||
onBlobbiesClick,
|
||||
onMissionsClick,
|
||||
@@ -2092,21 +2295,74 @@ function BlobbiBottomBar({
|
||||
hideEvolveButton = false,
|
||||
isIncubationAction = false,
|
||||
isEvolutionAction = false,
|
||||
// Bar preferences
|
||||
barPreferences,
|
||||
onEditBar,
|
||||
// Dev-only props
|
||||
onDevInstantTransition,
|
||||
onDevOpenEditor,
|
||||
onDevOpenEmotionPanel,
|
||||
}: BlobbiBottomBarProps) {
|
||||
// Determine what to show on missions badge:
|
||||
// - If all tasks complete during active process: show "!"
|
||||
// - If tasks remaining during active process: show count
|
||||
// - Otherwise: no badge
|
||||
// Works for BOTH incubating (hatch) and evolving processes
|
||||
const missionsBadge = allTasksComplete ? '!' : (isInTaskProcess && remainingTasksCount && remainingTasksCount > 0 ? remainingTasksCount : undefined);
|
||||
|
||||
const canBeCompanion = stage !== 'egg';
|
||||
const showEvolveButton = stage !== 'adult' && !hideEvolveButton;
|
||||
|
||||
// Handler map for customizable items
|
||||
const handlers: BarItemHandlers = useMemo(() => ({
|
||||
blobbies: onBlobbiesClick,
|
||||
missions: onMissionsClick,
|
||||
items: onShopClick,
|
||||
take_photo: onTakePhoto,
|
||||
set_companion: onSetAsCompanion,
|
||||
}), [onBlobbiesClick, onMissionsClick, onShopClick, onTakePhoto, onSetAsCompanion]);
|
||||
|
||||
// Icon map for customizable items
|
||||
const iconMap: Record<BarItemId, React.ReactNode> = useMemo(() => ({
|
||||
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={cn('size-4', isCurrentCompanion && 'text-green-500')} />,
|
||||
}), [isCurrentCompanion]);
|
||||
|
||||
// Label map
|
||||
const labelMap: Record<BarItemId, string> = {
|
||||
blobbies: 'Blobbies',
|
||||
missions: 'Missions',
|
||||
items: 'Items',
|
||||
take_photo: 'Photo',
|
||||
set_companion: isCurrentCompanion ? 'Companion' : 'Companion',
|
||||
};
|
||||
|
||||
// Badge map
|
||||
const badgeMap: Record<BarItemId, { badge?: number | string; variant?: 'default' | 'warning' | 'success' }> = useMemo(() => ({
|
||||
blobbies: {
|
||||
badge: needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined,
|
||||
variant: needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' as const : 'default' as const,
|
||||
},
|
||||
missions: {
|
||||
badge: missionsBadge,
|
||||
variant: allTasksComplete ? 'success' as const : 'default' as const,
|
||||
},
|
||||
items: {},
|
||||
take_photo: {},
|
||||
set_companion: {},
|
||||
}), [needyBlobbiesCount, missionsBadge, allTasksComplete]);
|
||||
|
||||
// Visible custom slots from preferences
|
||||
const visibleSlots = getVisibleSlots(barPreferences);
|
||||
|
||||
// Set of item IDs currently visible in the bar (used to skip duplicates in More)
|
||||
const visibleBarIds = useMemo(() => getVisibleBarIds(barPreferences), [barPreferences]);
|
||||
|
||||
// Split into left group (before center) and right group (after center)
|
||||
// Distribute: first half to left, rest to right
|
||||
const halfIdx = Math.ceil(visibleSlots.length / 2);
|
||||
const leftSlots = visibleSlots.slice(0, halfIdx);
|
||||
const rightSlots = visibleSlots.slice(halfIdx);
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-2">
|
||||
<div className="bg-card/95 backdrop-blur-md border border-border rounded-2xl px-1.5 sm:px-3 py-2 shadow-lg overflow-hidden">
|
||||
@@ -2114,23 +2370,20 @@ function BlobbiBottomBar({
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-0.5 sm:gap-2">
|
||||
{/* Left Group - aligned to end (closer to center) */}
|
||||
<div className="flex items-center justify-end gap-0 sm:gap-1 overflow-hidden">
|
||||
<BottomBarButton
|
||||
onClick={onBlobbiesClick}
|
||||
icon={<Egg className="size-4" />}
|
||||
label="Blobbies"
|
||||
badge={needyBlobbiesCount && needyBlobbiesCount > 0 ? needyBlobbiesCount : undefined}
|
||||
badgeVariant={needyBlobbiesCount && needyBlobbiesCount > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
<BottomBarButton
|
||||
onClick={onMissionsClick}
|
||||
icon={<Target className="size-4" />}
|
||||
label="Missions"
|
||||
badge={missionsBadge}
|
||||
badgeVariant={allTasksComplete ? 'success' : 'default'}
|
||||
/>
|
||||
{leftSlots.map((slot) => (
|
||||
<BottomBarButton
|
||||
key={slot.id}
|
||||
onClick={handlers[slot.id]}
|
||||
icon={iconMap[slot.id]}
|
||||
label={labelMap[slot.id]}
|
||||
badge={badgeMap[slot.id].badge}
|
||||
badgeVariant={badgeMap[slot.id].variant}
|
||||
highlighted={slot.highlighted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Center Action Button */}
|
||||
{/* Center Action Button (fixed) */}
|
||||
<button
|
||||
onClick={onActionsClick}
|
||||
className="flex items-center justify-center size-11 sm:size-12 -mt-3 sm:-mt-4 mx-1 sm:mx-2 rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 active:scale-95 transition-all border-4 border-background shrink-0"
|
||||
@@ -2140,9 +2393,19 @@ function BlobbiBottomBar({
|
||||
|
||||
{/* Right Group - aligned to start (closer to center) */}
|
||||
<div className="flex items-center justify-start gap-0 sm:gap-1 overflow-hidden">
|
||||
<BottomBarButton onClick={onShopClick} icon={<Package className="size-4" />} label="Items" />
|
||||
{rightSlots.map((slot) => (
|
||||
<BottomBarButton
|
||||
key={slot.id}
|
||||
onClick={handlers[slot.id]}
|
||||
icon={iconMap[slot.id]}
|
||||
label={labelMap[slot.id]}
|
||||
badge={badgeMap[slot.id].badge}
|
||||
badgeVariant={badgeMap[slot.id].variant}
|
||||
highlighted={slot.highlighted}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 3-dots menu */}
|
||||
{/* More dropdown (fixed) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -2153,11 +2416,32 @@ function BlobbiBottomBar({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="end">
|
||||
<DropdownMenuItem onClick={onTakePhoto}>
|
||||
<Camera className="size-4 mr-2" />
|
||||
Take a Photo
|
||||
</DropdownMenuItem>
|
||||
{canBeCompanion && (
|
||||
{/* Show items in More only when they're NOT already visible in the bar */}
|
||||
{!visibleBarIds.has('blobbies') && (
|
||||
<DropdownMenuItem onClick={onBlobbiesClick}>
|
||||
<Egg className="size-4 mr-2" />
|
||||
Blobbies
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!visibleBarIds.has('items') && (
|
||||
<DropdownMenuItem onClick={onShopClick}>
|
||||
<Package className="size-4 mr-2" />
|
||||
Items
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!visibleBarIds.has('missions') && (
|
||||
<DropdownMenuItem onClick={onMissionsClick}>
|
||||
<Target className="size-4 mr-2" />
|
||||
Missions
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!visibleBarIds.has('take_photo') && (
|
||||
<DropdownMenuItem onClick={onTakePhoto}>
|
||||
<Camera className="size-4 mr-2" />
|
||||
Take a Photo
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canBeCompanion && !visibleBarIds.has('set_companion') && (
|
||||
<DropdownMenuItem onClick={onSetAsCompanion} disabled={isUpdatingCompanion}>
|
||||
<Footprints className={cn('size-4 mr-2', isCurrentCompanion && 'text-green-500')} />
|
||||
{isCurrentCompanion ? 'Current Companion' : 'Set as Companion'}
|
||||
@@ -2175,6 +2459,11 @@ function BlobbiBottomBar({
|
||||
View Blobbi
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEditBar}>
|
||||
<Settings2 className="size-4 mr-2" />
|
||||
Edit action bar
|
||||
</DropdownMenuItem>
|
||||
{/* DEV ONLY: Developer tools */}
|
||||
{isLocalhostDev() && (onDevInstantTransition || onDevOpenEditor || onDevOpenEmotionPanel) && (
|
||||
<>
|
||||
@@ -2218,9 +2507,11 @@ interface BottomBarButtonProps {
|
||||
badge?: number | string;
|
||||
/** Badge color variant */
|
||||
badgeVariant?: 'default' | 'warning' | 'success';
|
||||
/** Show subtle highlight ring around this button */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default' }: BottomBarButtonProps) {
|
||||
function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default', highlighted }: BottomBarButtonProps) {
|
||||
// Determine if badge should show
|
||||
const showBadge = badge !== undefined && (typeof badge === 'string' || badge > 0);
|
||||
|
||||
@@ -2234,7 +2525,10 @@ function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default'
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]"
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]",
|
||||
highlighted && "ring-1 ring-primary/30 bg-accent/30",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
{icon}
|
||||
|
||||
+187
-78
@@ -1,43 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Bug, CalendarDays, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert, Tag } from 'lucide-react';
|
||||
import { Bug, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { parseChangelog } from '@/lib/changelog';
|
||||
import type { ChangelogCategory } from '@/lib/changelog';
|
||||
import type { ChangelogCategory, ChangelogEntry } from '@/lib/changelog';
|
||||
|
||||
const GITLAB_REPO = 'https://gitlab.com/soapbox-pub/ditto';
|
||||
|
||||
/** Per-category badge color + icon. */
|
||||
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; className: string }> = {
|
||||
Added: {
|
||||
icon: Plus,
|
||||
className: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
},
|
||||
Changed: {
|
||||
icon: RefreshCw,
|
||||
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
},
|
||||
Deprecated: {
|
||||
icon: Package,
|
||||
className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
},
|
||||
Removed: {
|
||||
icon: Minus,
|
||||
className: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
},
|
||||
Fixed: {
|
||||
icon: Bug,
|
||||
className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
},
|
||||
Security: {
|
||||
icon: ShieldAlert,
|
||||
className: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
},
|
||||
/** Per-category icon + color used as inline list bullets. */
|
||||
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; colorClass: string }> = {
|
||||
Added: { icon: Plus, colorClass: 'text-emerald-600 dark:text-emerald-400' },
|
||||
Changed: { icon: RefreshCw, colorClass: 'text-blue-600 dark:text-blue-400' },
|
||||
Deprecated: { icon: Package, colorClass: 'text-orange-600 dark:text-orange-400' },
|
||||
Removed: { icon: Minus, colorClass: 'text-red-600 dark:text-red-400' },
|
||||
Fixed: { icon: Bug, colorClass: 'text-amber-600 dark:text-amber-400' },
|
||||
Security: { icon: ShieldAlert, colorClass: 'text-purple-600 dark:text-purple-400' },
|
||||
};
|
||||
|
||||
/** Format "2026-03-26" as a readable date string. */
|
||||
@@ -92,55 +74,21 @@ export function ChangelogPage() {
|
||||
<>
|
||||
{isPreRelease && latestVersion && <PreReleaseBanner latestVersion={latestVersion} />}
|
||||
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.version} className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Version header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
|
||||
<Tag className="size-4 text-primary shrink-0" />
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-sm hover:underline"
|
||||
>
|
||||
v{entry.version}
|
||||
</a>
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
<span>{formatDate(entry.date)}</span>
|
||||
</a>
|
||||
<LatestRelease entry={entries[0]} />
|
||||
|
||||
{entries.length > 1 && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 pt-4 pb-1">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Past releases</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="divide-y divide-border">
|
||||
{entry.sections.map((section) => {
|
||||
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
|
||||
const Icon = style.icon;
|
||||
|
||||
return (
|
||||
<div key={section.category} className="px-4 py-3 space-y-2">
|
||||
<Badge variant="secondary" className={`gap-1 text-[10px] px-1.5 py-0 ${style.className}`}>
|
||||
<Icon className="size-3" />
|
||||
{section.category}
|
||||
</Badge>
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-foreground/90 pl-3 relative before:absolute before:left-0 before:top-[0.6em] before:size-1 before:rounded-full before:bg-muted-foreground/40">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{entries.slice(1).map((entry) => (
|
||||
<ChangelogEntryCard key={entry.version} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,6 +96,167 @@ export function ChangelogPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Hero treatment for the most recent release — no card, centered version + date. */
|
||||
function LatestRelease({ entry }: { entry: ChangelogEntry }) {
|
||||
const contentRef = useRef<HTMLUListElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
<div className="pt-2 pb-1 px-4">
|
||||
{/* Big centered version + date */}
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-center text-2xl font-bold tracking-tight hover:underline"
|
||||
>
|
||||
v{entry.version}
|
||||
</a>
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
{formatDate(entry.date)}
|
||||
</a>
|
||||
|
||||
{/* Items */}
|
||||
<div className="relative mt-4">
|
||||
<ul
|
||||
ref={contentRef}
|
||||
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
|
||||
className="space-y-2.5"
|
||||
>
|
||||
{entry.sections.flatMap((section) => {
|
||||
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
|
||||
const Icon = style.icon;
|
||||
|
||||
return section.items.map((item, i) => (
|
||||
<li key={`${section.category}-${i}`} className="flex gap-2 text-base text-foreground/90">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon className={`size-4 shrink-0 mt-1 cursor-default ${style.colorClass}`} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{section.category}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
));
|
||||
})}
|
||||
</ul>
|
||||
{!expanded && overflows && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overflows && (
|
||||
<button
|
||||
className="w-full text-sm text-primary hover:underline mt-1"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ENTRY_MAX_HEIGHT = 240; // px — entries taller than this get a "Read more" button
|
||||
|
||||
/** A single changelog release card with truncation for long entries. */
|
||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
const contentRef = useRef<HTMLUListElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Version header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-sm hover:underline"
|
||||
>
|
||||
v{entry.version}
|
||||
</a>
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
<span>{formatDate(entry.date)}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="relative">
|
||||
<ul
|
||||
ref={contentRef}
|
||||
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
|
||||
className="px-4 py-3 space-y-2.5"
|
||||
>
|
||||
{entry.sections.flatMap((section) => {
|
||||
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
|
||||
const Icon = style.icon;
|
||||
|
||||
return section.items.map((item, i) => (
|
||||
<li key={`${section.category}-${i}`} className="flex gap-2 text-sm text-foreground/90">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon className={`size-3.5 shrink-0 mt-[3px] cursor-default ${style.colorClass}`} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{section.category}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
));
|
||||
})}
|
||||
</ul>
|
||||
{!expanded && overflows && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overflows && (
|
||||
<button
|
||||
className="w-full text-sm text-primary hover:underline py-2"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Banner shown at the top of the changelog for untagged (pre-release) builds. */
|
||||
function PreReleaseBanner({ latestVersion }: { latestVersion: string }) {
|
||||
return (
|
||||
@@ -186,7 +295,7 @@ function ChangelogSkeleton() {
|
||||
<div className="space-y-4 pt-1">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
|
||||
@@ -50,7 +50,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
|
||||
description: `${title} on Nostr`,
|
||||
});
|
||||
|
||||
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
|
||||
const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
|
||||
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
|
||||
|
||||
const kinds = Array.isArray(kind) ? kind : [kind];
|
||||
|
||||
@@ -78,14 +78,15 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
|
||||
30030: 'emoji pack',
|
||||
30054: 'podcast episode',
|
||||
30055: 'podcast trailer',
|
||||
30063: 'release',
|
||||
3063: 'Zapstore asset',
|
||||
30063: 'Zapstore release',
|
||||
30311: 'stream',
|
||||
30315: 'status',
|
||||
30617: 'repository',
|
||||
30817: 'custom NIP',
|
||||
31922: 'calendar event',
|
||||
31923: 'calendar event',
|
||||
32267: 'app',
|
||||
32267: 'Zapstore app',
|
||||
34139: 'playlist',
|
||||
34236: 'divine',
|
||||
34550: 'community',
|
||||
@@ -469,7 +470,7 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
|
||||
actorPubkey={item.event.pubkey}
|
||||
icon={
|
||||
<span className="text-base leading-none size-4 flex items-center justify-center">
|
||||
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4" />
|
||||
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4 object-contain" />
|
||||
</span>
|
||||
}
|
||||
action={`reacted to your ${noun}`}
|
||||
@@ -569,7 +570,7 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
|
||||
actors={group.actors}
|
||||
icon={
|
||||
<span className="text-base leading-none size-4 flex items-center justify-center">
|
||||
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4" />
|
||||
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4 object-contain" />
|
||||
</span>
|
||||
}
|
||||
action={`reacted to your ${noun}`}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user