Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 283b31813c | |||
| 6e1197a067 | |||
| b7d1fbf860 | |||
| 8fde660075 | |||
| 50c7d67928 | |||
| e355c43925 | |||
| 696204870d | |||
| 0a7e01d17c | |||
| dd87bc96ec | |||
| a12d5db560 | |||
| 614634789c | |||
| 29696fa3d3 | |||
| ffc31e8e8f | |||
| 720a7e91fe | |||
| 05096e2cd9 | |||
| 05667460eb | |||
| b10dae7655 | |||
| c799b9efd6 | |||
| fe4834e157 | |||
| 5d972249a4 | |||
| f607a01577 | |||
| 1e232e6a9e | |||
| 431c388129 | |||
| 72b63dac21 | |||
| be82cb9626 | |||
| c2c6f711b5 | |||
| 3fba81a7d2 | |||
| 6f2b51197f | |||
| 00c801e9dc | |||
| 47e7d05cb9 | |||
| 4ef6d1b149 | |||
| badd19d27c | |||
| e67f90582b | |||
| 7fa6e574f8 | |||
| 9b36bf3325 | |||
| bc1c4cb7cf | |||
| 119f684fb3 | |||
| 45134ef9cc | |||
| db502b462c | |||
| ed083bfdad | |||
| 47811f9190 | |||
| ba99cdc51c | |||
| 7092f7306f | |||
| 357dd56de0 | |||
| fadec0574a | |||
| 469806886a | |||
| f7ab980ecd | |||
| c6b5ab2284 | |||
| 2231673ee6 | |||
| f8907475f9 | |||
| 4252841125 | |||
| ee8220c1f0 | |||
| 11e29646a7 | |||
| a9bab7f8e8 | |||
| 0b69ab51f4 | |||
| 2a32e79b13 | |||
| 39fc7549ac | |||
| 414f42e339 | |||
| 8e3f778f5b | |||
| bc83d08961 | |||
| 7d83273410 | |||
| fabcb4170d | |||
| 8b824f8cc9 | |||
| 3e429fe0b0 | |||
| a261934ab0 | |||
| 822ff13ac3 | |||
| afa475ecef | |||
| 853b5ead9c | |||
| a5746ee915 | |||
| fa3376ac4f | |||
| 6f0c10fe9b | |||
| 2f1bf0bca5 | |||
| 9be98d9a8d | |||
| c4dd8e7c3d | |||
| 42832b72e3 | |||
| e77436d02a | |||
| 302d7732ef | |||
| b09b4938d2 | |||
| 0a0d6de111 | |||
| 4e9b893822 | |||
| c60e87ad65 | |||
| 8e07ad515a | |||
| b4c4b8eb21 | |||
| 23ee6f1196 | |||
| 4b97baa428 | |||
| c8e844a19a | |||
| 205a252cac | |||
| ad604eae68 | |||
| 57064b4f40 | |||
| bb7b8da581 | |||
| 5683f6ea1e | |||
| 61c606822a | |||
| bc12331cd4 | |||
| 2478bf1c66 | |||
| ade9eb4999 | |||
| 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.
|
||||
|
||||
+117
-6
@@ -1,5 +1,116 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- 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 +147,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 +202,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 +225,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 +252,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.6.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.6.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +325,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.9;
|
||||
MARKETING_VERSION = 2.6.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+1912
-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.6.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",
|
||||
|
||||
+117
-6
@@ -1,5 +1,116 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- See who voted on each poll option -- tap the vote count to open a voters list with avatar stacks and per-option filter tabs
|
||||
- Poll votes now appear as activity cards in feeds and on detail pages
|
||||
|
||||
### Fixed
|
||||
- Threads and replies load more reliably by following relay and author hints when fetching parent events
|
||||
|
||||
## [2.5.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Lightbox now reliably appears above all content, not just when opened from photo galleries
|
||||
|
||||
## [2.5.0] - 2026-04-03
|
||||
|
||||
### Added
|
||||
- Run nsites and web apps directly inside Ditto -- hit the "Run" button on any nsite or app card to preview it in an overlay without leaving your feed
|
||||
- File uploads in the poll composer -- attach images and media to your polls
|
||||
- Blobbi posts now appear in the homepage feed
|
||||
|
||||
### Changed
|
||||
- Profile media sidebar fills remaining slots with photos from text posts when there aren't enough dedicated media posts
|
||||
- App cards now show banner images and improved layout
|
||||
|
||||
### Fixed
|
||||
- Lightbox no longer appears behind the right sidebar
|
||||
- Compose box corners are properly rounded
|
||||
- Clicking buttons or links inside a post card no longer accidentally navigates to the post detail page
|
||||
|
||||
## [2.4.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- Rich cards for Zapstore app releases and assets -- see download links, version info, platform badges, and hashes right in your feed
|
||||
|
||||
### Fixed
|
||||
- First-hatch tour now shows for accounts that were onboarded before the tour existed, so no one misses the hatching moment
|
||||
|
||||
## [2.4.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- First-hatch tour: a guided experience for hatching your very first Blobbi egg, with progressive crack animations, an inline card flow, and a reveal moment
|
||||
- Customizable bottom bar: rearrange or hide any item in the navigation bar to make Ditto feel like yours
|
||||
- Mission surface card in the feed that surfaces your active quests at a glance
|
||||
|
||||
### Changed
|
||||
- Missions redesigned as a quest board with collapsible cards and a lighter aesthetic
|
||||
- "Edit Profile" mission now completes when you update any profile field, not just wall-specific edits
|
||||
- Media tab on profiles now shows only photos, videos, and other media -- not plain text posts
|
||||
- Blobbi onboarding state now syncs to your profile so it follows you across devices
|
||||
|
||||
### Fixed
|
||||
- Notification dot no longer reappears after you've already marked notifications as read
|
||||
- Dialogs no longer fly up when the mobile keyboard opens
|
||||
|
||||
## [2.3.1] - 2026-04-02
|
||||
|
||||
### Changed
|
||||
- Drafts now save instantly to your device and sync to relays in the background, with a cloud sync indicator so you always know the status
|
||||
|
||||
### Fixed
|
||||
- Dialogs stay visible above the keyboard on mobile instead of getting hidden behind it
|
||||
- Editing an existing article no longer incorrectly warns about a duplicate slug
|
||||
- Switching between rich text and markdown source mode no longer clears your content
|
||||
- Fix crash when editing in markdown source mode
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- 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 +147,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 +202,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 +225,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 +252,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
|
||||
|
||||
|
||||
+27
-9
@@ -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";
|
||||
|
||||
@@ -49,6 +51,7 @@ const hardcodedConfig: AppConfig = {
|
||||
appName: "Ditto",
|
||||
appId: "ditto",
|
||||
homePage: "feed",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
autoShareTheme: true,
|
||||
@@ -121,11 +124,11 @@ const hardcodedConfig: AppConfig = {
|
||||
"feed",
|
||||
"notifications",
|
||||
"search",
|
||||
"themes",
|
||||
"letters",
|
||||
"badges",
|
||||
"blobbi",
|
||||
"theme",
|
||||
"badges",
|
||||
"emojis",
|
||||
"letters",
|
||||
"themes",
|
||||
"settings",
|
||||
"help",
|
||||
],
|
||||
@@ -148,15 +151,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 +202,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 })));
|
||||
@@ -74,6 +77,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -136,6 +140,8 @@ export function AppRouter() {
|
||||
return (
|
||||
<AudioPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<MinimizedAudioBar />
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
@@ -146,6 +152,9 @@ export function AppRouter() {
|
||||
</Suspense>
|
||||
</BlobbiActionsProvider>
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
@@ -207,6 +216,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 +225,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -24,7 +25,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
@@ -34,8 +40,6 @@ export interface UseBlobbiCareActivityParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
export interface CareActivityResult {
|
||||
@@ -59,8 +63,8 @@ export interface CareActivityResult {
|
||||
export function useBlobbiCareActivity({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiCareActivityParams) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -78,12 +82,24 @@ export function useBlobbiCareActivity({
|
||||
throw new Error('No companion available');
|
||||
}
|
||||
|
||||
// Fetch fresh companion from relays (read-modify-write pattern)
|
||||
const freshEvents = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [companion.d],
|
||||
}]);
|
||||
const freshCompanion = freshEvents
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map(e => parseBlobbiEvent(e))
|
||||
.find(Boolean) ?? companion;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Calculate what the streak update should be
|
||||
// Calculate what the streak update should be using fresh data
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
freshCompanion.careStreak,
|
||||
freshCompanion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
@@ -96,29 +112,29 @@ export function useBlobbiCareActivity({
|
||||
};
|
||||
}
|
||||
|
||||
// Get the tag updates
|
||||
const streakUpdates = getStreakTagUpdates(companion, now);
|
||||
// Get the tag updates using fresh data
|
||||
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
|
||||
|
||||
if (!streakUpdates) {
|
||||
// Shouldn't happen if wasUpdated is true, but handle gracefully
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
newStreak: freshCompanion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Build updated tags
|
||||
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
|
||||
// Build updated tags from fresh data
|
||||
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
|
||||
|
||||
// Publish the updated event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content,
|
||||
content: freshCompanion.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update local cache
|
||||
// Update local cache (optimistic — no invalidation needed)
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// Update session tracker
|
||||
@@ -128,9 +144,9 @@ export function useBlobbiCareActivity({
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CareActivity] Streak updated:', {
|
||||
action: result.action,
|
||||
previousStreak: companion.careStreak,
|
||||
previousStreak: freshCompanion.careStreak,
|
||||
newStreak: result.newStreak,
|
||||
lastDay: companion.careStreakLastDay,
|
||||
lastDay: freshCompanion.careStreakLastDay,
|
||||
newDay: result.newLastDay,
|
||||
});
|
||||
}
|
||||
@@ -141,11 +157,6 @@ export function useBlobbiCareActivity({
|
||||
action: result.action,
|
||||
};
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.wasUpdated) {
|
||||
invalidateCompanion();
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[CareActivity] Failed to update streak:', error);
|
||||
},
|
||||
|
||||
@@ -69,10 +69,6 @@ export interface UseBlobbiDirectActionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration happened) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,8 +88,6 @@ export function useBlobbiDirectAction({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiDirectActionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -189,12 +183,6 @@ export function useBlobbiDirectAction({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
|
||||
@@ -66,10 +66,6 @@ export interface UseStartIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +108,6 @@ export function useStartIncubation({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
@@ -269,12 +263,6 @@ export function useStartIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -329,10 +317,6 @@ export interface UseStopIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,8 +347,6 @@ export function useStopIncubation({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -435,12 +417,6 @@ export function useStopIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -480,10 +456,6 @@ export interface UseStartEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,8 +483,6 @@ export function useStartEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -585,12 +555,6 @@ export function useStartEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -631,10 +595,6 @@ export interface UseStopEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -665,8 +625,6 @@ export function useStopEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -736,12 +694,6 @@ export function useStopEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -784,10 +736,6 @@ export interface UseSyncTaskCompletionsParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -827,8 +775,6 @@ export function useSyncTaskCompletions({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseSyncTaskCompletionsParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -923,11 +869,6 @@ export function useSyncTaskCompletions({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Published successfully:', tagsToAdd);
|
||||
|
||||
@@ -69,10 +69,6 @@ export interface UseBlobbiStageTransitionParams {
|
||||
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +109,6 @@ export function useBlobbiHatch({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -220,12 +214,6 @@ export function useBlobbiHatch({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'egg',
|
||||
@@ -268,8 +256,6 @@ export function useBlobbiEvolve({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -376,12 +362,6 @@ export function useBlobbiEvolve({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'baby',
|
||||
|
||||
@@ -80,10 +80,6 @@ export interface UseBlobbiUseInventoryItemParams {
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Update profile event in local cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
// Import NostrEvent type
|
||||
@@ -107,8 +103,6 @@ export function useBlobbiUseInventoryItem({
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -145,15 +139,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -407,27 +392,29 @@ export function useBlobbiUseInventoryItem({
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
|
||||
// instead of profile.storage/profile.allTags to avoid restoring
|
||||
// stale/legacy values after migration
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
// Only decrement storage if the item actually exists in inventory.
|
||||
// Items are free to use regardless of inventory state.
|
||||
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
|
||||
if (hasItemInStorage) {
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
invalidateProfile();
|
||||
// No query invalidation needed — the optimistic updates above keep the
|
||||
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
|
||||
// before every mutation (read-modify-write pattern).
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Kind for wall edit events */
|
||||
export const KIND_WALL_EDIT = 16769;
|
||||
/** Kind for custom profile tabs event */
|
||||
export const KIND_PROFILE_TABS = 16769;
|
||||
|
||||
/** Required themes for evolve task */
|
||||
export const EVOLVE_REQUIRED_THEMES = 3;
|
||||
@@ -117,7 +117,7 @@ export function isValidEvolvePost(event: NostrEvent, blobbiName: string): boolea
|
||||
* 2. Create 3 Color Moments (kind 3367)
|
||||
* 3. Create 1 Evolve Post (kind 1) - lighter than hatch, evolve-specific
|
||||
* 4. Interact 21 times (tracked via companion.tasks cache)
|
||||
* 5. Edit Wall once (kind 16769)
|
||||
* 5. Edit Profile once (kind 0 profile metadata OR kind 16769 custom tabs)
|
||||
*
|
||||
* DYNAMIC TASK (stat-based, NEVER cached):
|
||||
* 6. Maintain All Stats >= 80
|
||||
@@ -165,14 +165,14 @@ export function useEvolveTasks(
|
||||
since: stateStartedAt,
|
||||
limit: 50, // Only need 1 valid evolve post
|
||||
},
|
||||
// Wall edits after start
|
||||
// Custom profile tabs after start
|
||||
{
|
||||
kinds: [KIND_WALL_EDIT],
|
||||
kinds: [KIND_PROFILE_TABS],
|
||||
authors: [pubkey],
|
||||
since: stateStartedAt,
|
||||
limit: 1, // Only need 1
|
||||
},
|
||||
// Profile metadata after start (for Blobbi shape check)
|
||||
// Profile metadata after start (for Blobbi shape check + profile edit mission)
|
||||
{
|
||||
kinds: [KIND_PROFILE_METADATA],
|
||||
authors: [pubkey],
|
||||
@@ -197,8 +197,8 @@ export function useEvolveTasks(
|
||||
e.kind === KIND_SHORT_TEXT_NOTE && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
const wallEditEvents = events.filter(e =>
|
||||
e.kind === KIND_WALL_EDIT && e.created_at >= stateStartedAt
|
||||
const profileTabsEvents = events.filter(e =>
|
||||
e.kind === KIND_PROFILE_TABS && e.created_at >= stateStartedAt
|
||||
);
|
||||
|
||||
// Get latest profile after start
|
||||
@@ -211,7 +211,7 @@ export function useEvolveTasks(
|
||||
themeEvents,
|
||||
colorMomentEvents,
|
||||
postEvents,
|
||||
wallEditEvents,
|
||||
profileTabsEvents,
|
||||
profileAfter,
|
||||
};
|
||||
},
|
||||
@@ -287,20 +287,21 @@ export function useEvolveTasks(
|
||||
// No action - just interact with Blobbi
|
||||
});
|
||||
|
||||
// 5. Edit Wall once (PERSISTENT)
|
||||
const wallEditCount = data?.wallEditEvents?.length ?? 0;
|
||||
const hasWallEdit = wallEditCount >= 1;
|
||||
// 5. Edit Profile once (PERSISTENT) — kind 0 profile metadata OR kind 16769 custom tabs
|
||||
const hasTabsEdit = (data?.profileTabsEvents?.length ?? 0) >= 1;
|
||||
const hasMetadataEdit = !!data?.profileAfter;
|
||||
const hasProfileEdit = hasTabsEdit || hasMetadataEdit;
|
||||
tasks.push({
|
||||
id: 'edit_wall',
|
||||
name: 'Edit Your Wall',
|
||||
description: 'Customize your profile wall',
|
||||
current: hasWallEdit ? 1 : 0,
|
||||
id: 'edit_profile',
|
||||
name: 'Edit Your Profile',
|
||||
description: 'Update your profile info or customize your profile tabs',
|
||||
current: hasProfileEdit ? 1 : 0,
|
||||
required: 1,
|
||||
completed: hasWallEdit,
|
||||
completed: hasProfileEdit,
|
||||
type: 'persistent',
|
||||
action: 'navigate',
|
||||
actionTarget: '/settings/profile',
|
||||
actionLabel: 'Edit Wall',
|
||||
actionLabel: 'Edit Profile',
|
||||
});
|
||||
|
||||
// ─── Compute DYNAMIC Task (stat-based, NEVER cached) ───
|
||||
|
||||
@@ -34,10 +34,10 @@ export const KIND_SHORT_TEXT_NOTE = 1;
|
||||
export const HATCH_REQUIRED_INTERACTIONS = 7;
|
||||
|
||||
/** Required hashtags for the Blobbi post (excludes Blobbi name, which is dynamic) */
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi', 'ditto', 'nostr'];
|
||||
export const BLOBBI_POST_REQUIRED_HASHTAGS = ['blobbi'];
|
||||
|
||||
/** Prefix text for Blobbi hatch post */
|
||||
export const BLOBBI_POST_PREFIX = 'Hello Nostr! Posting to hatch';
|
||||
/** Prefix text for Blobbi hatch post (the Blobbi name is appended after this) */
|
||||
export const BLOBBI_POST_PREFIX = 'Posting to hatch';
|
||||
|
||||
// Legacy export for backwards compatibility
|
||||
export const REQUIRED_INTERACTIONS = HATCH_REQUIRED_INTERACTIONS;
|
||||
@@ -110,16 +110,28 @@ export interface HatchTasksResult {
|
||||
|
||||
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the required phrase for a hatch post.
|
||||
* Format: "Posting to hatch {CapitalizedName} #blobbi"
|
||||
*/
|
||||
export function buildHatchPhrase(blobbiName: string): string {
|
||||
const capitalized = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
return `${BLOBBI_POST_PREFIX} ${capitalized} #blobbi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is a valid Blobbi hatch post.
|
||||
* Must contain the required prefix and all required hashtags including the Blobbi name.
|
||||
* The post must contain the required phrase: "Posting to hatch {Name} #blobbi"
|
||||
* The user may add extra text before or after it.
|
||||
*
|
||||
* @param event - The Nostr event to validate
|
||||
* @param blobbiName - The Blobbi's name (will be sanitized and checked as hashtag)
|
||||
* @param blobbiName - The Blobbi's name
|
||||
*/
|
||||
export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean {
|
||||
// Check content starts with prefix
|
||||
if (!event.content.startsWith(BLOBBI_POST_PREFIX)) {
|
||||
const phrase = buildHatchPhrase(blobbiName);
|
||||
|
||||
// The phrase must appear somewhere in the content
|
||||
if (!event.content.includes(phrase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -128,18 +140,12 @@ export function isValidHatchPost(event: NostrEvent, blobbiName: string): boolean
|
||||
.filter(tag => tag[0] === 't')
|
||||
.map(tag => tag[1]?.toLowerCase());
|
||||
|
||||
// All required hashtags must be present
|
||||
// All required hashtags must be present as t tags
|
||||
const hasRequiredHashtags = BLOBBI_POST_REQUIRED_HASHTAGS.every(required =>
|
||||
hashtags.includes(required.toLowerCase())
|
||||
);
|
||||
|
||||
if (!hasRequiredHashtags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blobbi name hashtag must also be present
|
||||
const blobbiHashtag = sanitizeToHashtag(blobbiName);
|
||||
return hashtags.includes(blobbiHashtag);
|
||||
return hasRequiredHashtags;
|
||||
}
|
||||
|
||||
// Legacy function name for backwards compatibility
|
||||
|
||||
@@ -55,8 +55,6 @@ export {
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
@@ -70,7 +68,7 @@ export {
|
||||
useEvolveTasks,
|
||||
getEvolveInteractionCount,
|
||||
isValidEvolvePost,
|
||||
KIND_WALL_EDIT,
|
||||
KIND_PROFILE_TABS,
|
||||
EVOLVE_REQUIRED_THEMES,
|
||||
EVOLVE_REQUIRED_COLOR_MOMENTS,
|
||||
EVOLVE_REQUIRED_POSTS,
|
||||
|
||||
@@ -17,12 +17,14 @@ import { useMemo, memo, type RefObject } from 'react';
|
||||
|
||||
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
|
||||
|
||||
@@ -248,7 +250,14 @@ export function BlobbiCompanionVisual({
|
||||
)}
|
||||
style={{ transformOrigin: 'center bottom' }}
|
||||
>
|
||||
{(companion.stage === 'baby' || companion.stage === 'adult') && (
|
||||
{companion.stage === 'egg' ? (
|
||||
<BlobbiStageVisual
|
||||
companion={companion as unknown as BlobbiCompanion}
|
||||
size="sm"
|
||||
animated={false}
|
||||
className="size-full"
|
||||
/>
|
||||
) : (
|
||||
<MemoizedBlobbiVisual
|
||||
stage={companion.stage}
|
||||
blobbi={blobbi}
|
||||
|
||||
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging - let gravity take over.
|
||||
* End dragging - hold position where dropped.
|
||||
*/
|
||||
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
|
||||
return {
|
||||
...motion,
|
||||
isDragging: false,
|
||||
// If already at or below ground, snap to ground
|
||||
isGrounded: motion.position.y >= groundY,
|
||||
// Always treat as grounded so companion holds position where dropped
|
||||
isGrounded: true,
|
||||
position: {
|
||||
...motion.position,
|
||||
y: motion.position.y >= groundY ? groundY : motion.position.y,
|
||||
// Clamp to ground if below it
|
||||
y: Math.min(motion.position.y, groundY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
// Track if first entry has completed (for position initialization)
|
||||
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
|
||||
|
||||
// Track viewport size
|
||||
// Track viewport size — listen to both window resize and visualViewport
|
||||
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setViewport({
|
||||
@@ -114,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate bounds and positions
|
||||
|
||||
@@ -80,9 +80,6 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
|
||||
|
||||
if (!blobbi) return null;
|
||||
|
||||
// Only baby and adult can be companions
|
||||
if (blobbi.stage === 'egg') return null;
|
||||
|
||||
// Use projected stats if available, otherwise fall back to base stats
|
||||
const stats = projectedState?.stats ?? blobbi.stats;
|
||||
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
* idle -> rising -> inspecting -> entering -> complete
|
||||
*
|
||||
* Route change behavior:
|
||||
* - Cancels current entry immediately
|
||||
* - Waits 1 second
|
||||
* - Restarts entry for the new page
|
||||
* - Companion keeps its current position (no re-entry animation)
|
||||
* - Only initial mount and companion changes trigger entry animations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -310,20 +309,11 @@ export function useBlobbiEntryAnimation({
|
||||
// Random entry type for new companion (fall or rise)
|
||||
const entryType: EntryType = Math.random() < 0.5 ? 'fall' : 'rise';
|
||||
startEntry(entryType);
|
||||
} else if (routeChanged && companionId) {
|
||||
// Route changed - determine direction for new route
|
||||
const entryType = getEntryDirection(previousPath, pathname, sidebarOrder);
|
||||
|
||||
// Immediately hide Blobbi and cancel current entry
|
||||
cancelEntry();
|
||||
setIsHiddenForTransition(true);
|
||||
|
||||
// Wait 1 second, then start the new entry animation
|
||||
routeChangeTimeoutRef.current = setTimeout(() => {
|
||||
startEntry(entryType);
|
||||
}, entryConfig.routeChangeRestartDelay);
|
||||
} else if (routeChanged) {
|
||||
// Route changed - companion keeps its position, no re-entry animation.
|
||||
// Just update the ref so future changes compare against the new path.
|
||||
}
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
|
||||
|
||||
/**
|
||||
* Animation loop for FALL entry.
|
||||
|
||||
@@ -69,14 +69,13 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
/** Optimistically update the TanStack cache so the companion reacts immediately. */
|
||||
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
return;
|
||||
}
|
||||
if (!parsed) return;
|
||||
|
||||
// Optimistically update ALL blobbi-collection queries for this user.
|
||||
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
|
||||
// partial matching to find all entries regardless of dList shape.
|
||||
// No invalidation needed — we fetched fresh from relays before mutating,
|
||||
// so the optimistic update is the correct state.
|
||||
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
|
||||
const matchingQueries = queryClient.getQueriesData<CollectionData>({
|
||||
queryKey: ['blobbi-collection', pubkey],
|
||||
@@ -90,9 +89,6 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// Also invalidate for background refetch to ensure eventual consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
}, [queryClient]);
|
||||
|
||||
const toggleSleep = useCallback(async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -8,12 +9,18 @@ import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
BLOBBONAUT_PROFILE_KINDS,
|
||||
getBlobbonautQueryDValues,
|
||||
buildMigrationTags,
|
||||
generatePetId10,
|
||||
getCanonicalBlobbiD,
|
||||
isValidBlobbiEvent,
|
||||
isValidBlobbonautEvent,
|
||||
isLegacyBlobbonautKind,
|
||||
migratePetInHas,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbiEvent,
|
||||
parseBlobbonautEvent,
|
||||
parseStorageTags,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
@@ -52,10 +59,6 @@ export interface EnsureCanonicalOptions {
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update localStorage selection if it was pointing to legacy d */
|
||||
updateStoredSelectedD?: (newD: string) => void;
|
||||
/** Callback to invalidate companion query */
|
||||
invalidateCompanion?: () => void;
|
||||
/** Callback to invalidate profile query */
|
||||
invalidateProfile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +114,7 @@ export interface EnsureCanonicalResult {
|
||||
* ```
|
||||
*/
|
||||
export function useBlobbiMigration() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -134,8 +138,6 @@ export function useBlobbiMigration() {
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
} = options;
|
||||
|
||||
if (!user?.pubkey) {
|
||||
@@ -190,7 +192,8 @@ export function useBlobbiMigration() {
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
// Update query caches
|
||||
// Update query caches (optimistic — no invalidation needed since we
|
||||
// fetch fresh from relays before every mutation)
|
||||
updateProfileEvent(profileEvent);
|
||||
updateCompanionEvent(canonicalEvent);
|
||||
|
||||
@@ -200,10 +203,6 @@ export function useBlobbiMigration() {
|
||||
updateStoredSelectedD(canonicalD);
|
||||
}
|
||||
|
||||
// Invalidate queries to refetch fresh data
|
||||
invalidateCompanion?.();
|
||||
invalidateProfile?.();
|
||||
|
||||
toast({
|
||||
title: 'Pet upgraded!',
|
||||
description: `${companion.name} has been migrated to the new format.`,
|
||||
@@ -237,29 +236,102 @@ export function useBlobbiMigration() {
|
||||
}
|
||||
}, [user?.pubkey, publishEvent]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest companion event directly from relays, bypassing cache.
|
||||
* This is the read step of the read-modify-write pattern.
|
||||
*/
|
||||
const fetchFreshCompanion = useCallback(async (
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<BlobbiCompanion | null> => {
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag],
|
||||
}]);
|
||||
|
||||
const validEvents = events
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest profile event directly from relays, bypassing cache.
|
||||
*/
|
||||
const fetchFreshProfile = useCallback(async (
|
||||
pubkey: string,
|
||||
): Promise<BlobbonautProfile | null> => {
|
||||
const dValues = getBlobbonautQueryDValues(pubkey);
|
||||
const events = await nostr.query([{
|
||||
kinds: [...BLOBBONAUT_PROFILE_KINDS],
|
||||
authors: [pubkey],
|
||||
'#d': dValues,
|
||||
}]);
|
||||
|
||||
const validEvents = events.filter(isValidBlobbonautEvent);
|
||||
if (validEvents.length === 0) return null;
|
||||
|
||||
// Prefer current kind over legacy
|
||||
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
|
||||
if (currentKindEvents.length > 0) {
|
||||
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
|
||||
if (legacyKindEvents.length > 0) {
|
||||
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Ensure a Blobbi is in canonical format before performing an action.
|
||||
*
|
||||
* CRITICAL: This fetches fresh data from relays (read-modify-write pattern)
|
||||
* instead of using potentially stale cache data. This prevents state resets
|
||||
* caused by publishing over a newer event with stale cached data.
|
||||
*
|
||||
* If the companion is legacy, it will be migrated first.
|
||||
* Returns the canonical companion to use for the action.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if Blobbi is legacy
|
||||
* 2. If legacy: migrate Blobbi
|
||||
* 3. Return the resolved canonical Blobbi
|
||||
* 1. Fetch fresh companion + profile from relays
|
||||
* 2. Check if Blobbi is legacy
|
||||
* 3. If legacy: migrate Blobbi
|
||||
* 4. Return the resolved canonical Blobbi with fresh data
|
||||
*
|
||||
* All interaction handlers should call this before publishing events.
|
||||
*/
|
||||
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<EnsureCanonicalResult | null> => {
|
||||
const { companion, profile } = options;
|
||||
if (!user?.pubkey) return null;
|
||||
|
||||
const { companion: cachedCompanion, profile: cachedProfile } = options;
|
||||
|
||||
// Fetch fresh data from relays (read step of read-modify-write)
|
||||
const [freshCompanion, freshProfile] = await Promise.all([
|
||||
fetchFreshCompanion(user.pubkey, cachedCompanion.d),
|
||||
fetchFreshProfile(user.pubkey),
|
||||
]);
|
||||
|
||||
// Use fresh data, falling back to cached only if relay fetch returned nothing
|
||||
const companion = freshCompanion ?? cachedCompanion;
|
||||
const profile = freshProfile ?? cachedProfile;
|
||||
|
||||
// Check if the companion needs migration
|
||||
if (companion.isLegacy) {
|
||||
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
|
||||
|
||||
const migrationResult = await migrateLegacyBlobbi(options);
|
||||
// Use fresh data in migration options
|
||||
const migrationOptions = { ...options, companion, profile };
|
||||
const migrationResult = await migrateLegacyBlobbi(migrationOptions);
|
||||
|
||||
if (!migrationResult) {
|
||||
// Migration failed, cannot proceed with action
|
||||
@@ -279,7 +351,7 @@ export function useBlobbiMigration() {
|
||||
};
|
||||
}
|
||||
|
||||
// Companion is already canonical, return profile as-is
|
||||
// Companion is already canonical, return fresh data
|
||||
return {
|
||||
wasMigrated: false,
|
||||
companion,
|
||||
@@ -288,7 +360,7 @@ export function useBlobbiMigration() {
|
||||
profileAllTags: profile.allTags,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [migrateLegacyBlobbi]);
|
||||
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
|
||||
|
||||
return {
|
||||
/** Migrate a legacy Blobbi to canonical format */
|
||||
|
||||
@@ -132,7 +132,10 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
|
||||
// Helper to invalidate and refetch after publishing
|
||||
// Helper to invalidate and refetch after publishing.
|
||||
// NOTE: In most mutation paths this is no longer needed — the read-modify-write
|
||||
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
|
||||
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
|
||||
const invalidate = useCallback(() => {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -141,36 +144,38 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update)
|
||||
// Update a single companion event in the query cache (optimistic update).
|
||||
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
|
||||
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
|
||||
// and companion layer cache stay in sync (they use different d-tag lists).
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
(prev) => {
|
||||
if (!prev) {
|
||||
return {
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
};
|
||||
}
|
||||
|
||||
// Update the specific companion in the record
|
||||
const newCompanionsByD = {
|
||||
...prev.companionsByD,
|
||||
[parsed.d]: parsed,
|
||||
};
|
||||
|
||||
// Rebuild companions array from the record
|
||||
const newCompanions = Object.values(newCompanionsByD);
|
||||
|
||||
return {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: newCompanions,
|
||||
};
|
||||
}
|
||||
);
|
||||
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
|
||||
const matchingQueries = queryClient.getQueriesData<CollectionData>({
|
||||
queryKey: ['blobbi-collection', user.pubkey],
|
||||
});
|
||||
|
||||
for (const [queryKey, data] of matchingQueries) {
|
||||
if (!data) continue;
|
||||
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
|
||||
queryClient.setQueryData<CollectionData>(queryKey, {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// If no existing queries matched (first load), set our own query key
|
||||
if (matchingQueries.length === 0) {
|
||||
queryClient.setQueryData<CollectionData>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
{
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Memoize return values for stability
|
||||
@@ -190,7 +195,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
isStale: query.isStale,
|
||||
/** Query error if any */
|
||||
error: query.error,
|
||||
/** Invalidate and refetch the collection */
|
||||
/** Invalidate and refetch the collection (use only when d-tag set changes, not after mutations) */
|
||||
invalidate,
|
||||
/** Optimistically update a single companion in the cache */
|
||||
updateCompanionEvent,
|
||||
|
||||
@@ -110,7 +110,7 @@ export function toEggGraphicVisualBlobbi(
|
||||
companion: BlobbiCompanion,
|
||||
themeVariant: EggThemeVariant = DEFAULT_THEME_VARIANT
|
||||
): EggVisualBlobbi {
|
||||
const { visualTraits, stage, allTags } = companion;
|
||||
const { visualTraits, stage, allTags = [] } = companion;
|
||||
|
||||
return {
|
||||
// Colors pass through directly (already CSS hex values)
|
||||
|
||||
@@ -976,7 +976,8 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
event,
|
||||
d,
|
||||
currentCompanion: getTagValue(tags, 'current_companion'),
|
||||
onboardingDone: parseBooleanTag(tags, 'onboarding_done', false),
|
||||
onboardingDone: parseBooleanTag(tags, 'blobbi_onboarding_done', false)
|
||||
|| parseBooleanTag(tags, 'onboarding_done', false),
|
||||
name: getTagValue(tags, 'name'),
|
||||
has: getTagValues(tags, 'has'),
|
||||
coins: parseNumericTag(tags, 'coins') ?? 0,
|
||||
@@ -996,7 +997,7 @@ export function buildBlobbonautTags(pubkey: string): string[][] {
|
||||
return [
|
||||
['d', getCanonicalBlobbonautD(pubkey)],
|
||||
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
|
||||
['onboarding_done', 'false'],
|
||||
['blobbi_onboarding_done', 'false'],
|
||||
['pettingLevel', '0'],
|
||||
];
|
||||
}
|
||||
@@ -1138,7 +1139,7 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
* These tags are controlled by the application and may be overwritten.
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'onboarding_done', 'has', 'storage',
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
@@ -1365,17 +1366,44 @@ export function profileNeedsPettingLevelNormalization(profile: BlobbonautProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Build updated tags for normalizing a profile to include pettingLevel.
|
||||
* Preserves all existing tags and adds pettingLevel: 0 if missing.
|
||||
* Check if a profile uses the legacy `onboarding_done` tag instead of the
|
||||
* new `blobbi_onboarding_done` tag. Returns true if migration is needed.
|
||||
*/
|
||||
export function profileNeedsOnboardingTagMigration(profile: BlobbonautProfile): boolean {
|
||||
const hasNewTag = profile.allTags.some(([name]) => name === 'blobbi_onboarding_done');
|
||||
const hasOldTag = profile.allTags.some(([name]) => name === 'onboarding_done');
|
||||
// Needs migration if: has old tag but not the new one
|
||||
return !hasNewTag && hasOldTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build updated tags for normalizing a profile.
|
||||
* Handles:
|
||||
* - Adding pettingLevel: 0 if missing
|
||||
* - Migrating onboarding_done → blobbi_onboarding_done
|
||||
*
|
||||
* Preserves all existing tags except the ones being migrated.
|
||||
*/
|
||||
export function buildNormalizedProfileTags(profile: BlobbonautProfile): string[][] {
|
||||
if (!profileNeedsPettingLevelNormalization(profile)) {
|
||||
return profile.allTags;
|
||||
let tags = profile.allTags;
|
||||
let changed = false;
|
||||
|
||||
// Normalize pettingLevel
|
||||
if (profileNeedsPettingLevelNormalization(profile)) {
|
||||
tags = updateBlobbonautTags(tags, { pettingLevel: '0' });
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return updateBlobbonautTags(profile.allTags, {
|
||||
pettingLevel: '0',
|
||||
});
|
||||
|
||||
// Migrate onboarding_done → blobbi_onboarding_done
|
||||
if (profileNeedsOnboardingTagMigration(profile)) {
|
||||
const oldValue = tags.find(([name]) => name === 'onboarding_done')?.[1] ?? 'false';
|
||||
// Remove old tag, add new tag
|
||||
tags = tags.filter(([name]) => name !== 'onboarding_done');
|
||||
tags = updateBlobbonautTags(tags, { blobbi_onboarding_done: oldValue });
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? tags : profile.allTags;
|
||||
}
|
||||
|
||||
// ─── Query Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -527,8 +527,10 @@ export function BlobbiDevEditor({
|
||||
onCheckedChange={setBreedingReady}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* IMPORTANT: This hook should only be used in development mode.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -24,8 +24,6 @@ interface UseBlobbiDevUpdateParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
interface DevUpdateResult {
|
||||
@@ -50,11 +48,9 @@ function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
export function useBlobbiDevUpdate({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiDevUpdateParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (updates: BlobbiDevUpdates): Promise<DevUpdateResult> => {
|
||||
@@ -169,12 +165,6 @@ export function useBlobbiDevUpdate({
|
||||
|
||||
// ─── Update Caches ───
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate collection queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
|
||||
return {
|
||||
previousStage: companion.stage,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { EggVisualBlobbi } from '../types/egg.types';
|
||||
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
|
||||
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
|
||||
@@ -25,6 +25,29 @@ export interface EggStatusEffects {
|
||||
happy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tour visual states that the egg can display.
|
||||
* Driven by the tour orchestration layer, not by EggGraphic itself.
|
||||
*
|
||||
* - idle: no tour effects
|
||||
* - show_hatch_card: initial crack visible + auto-wiggle every 2.5s
|
||||
* - glowing_waiting_click: enhanced glow + auto-wiggle, waiting for tap
|
||||
* - crack_stage_1: crack expands (click 1)
|
||||
* - crack_stage_2: crack expands more (click 2)
|
||||
* - crack_stage_3: final crack (click 3)
|
||||
* - opening: shell splits open
|
||||
* - hatching: bright light + reveal
|
||||
*/
|
||||
export type EggTourVisualState =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'glowing_waiting_click'
|
||||
| 'crack_stage_1'
|
||||
| 'crack_stage_2'
|
||||
| 'crack_stage_3'
|
||||
| 'opening'
|
||||
| 'hatching';
|
||||
|
||||
interface EggGraphicProps {
|
||||
blobbi?: EggVisualBlobbi; // Visual blobbi object for visual properties
|
||||
sizeVariant?: 'tiny' | 'small' | 'medium' | 'large'; // Internal scaling only, NOT layout size
|
||||
@@ -36,6 +59,10 @@ interface EggGraphicProps {
|
||||
forceInlineSvg?: boolean; // New prop to guarantee inline SVG
|
||||
/** Status effects for egg-stage visual feedback */
|
||||
statusEffects?: EggStatusEffects;
|
||||
/** Tour visual state - driven externally by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +141,8 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
warmth = 50,
|
||||
forceInlineSvg: _forceInlineSvg = false,
|
||||
statusEffects,
|
||||
tourVisualState = 'idle',
|
||||
onTourEggClick,
|
||||
}) => {
|
||||
// sizeVariant controls ONLY internal scaling/details, NOT layout dimensions
|
||||
// Parent container controls actual rendered width/height via slot
|
||||
@@ -152,14 +181,62 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
const [isTapWiggling, setIsTapWiggling] = useState(false);
|
||||
|
||||
const handleEggClick = useCallback(() => {
|
||||
// Tour interactive steps: forward click to tour controller
|
||||
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
|
||||
setIsTapWiggling(true);
|
||||
onTourEggClick();
|
||||
return;
|
||||
}
|
||||
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
|
||||
setIsTapWiggling(true);
|
||||
}, [isTapWiggling, cracking]);
|
||||
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
|
||||
|
||||
const handleWiggleEnd = useCallback(() => {
|
||||
setIsTapWiggling(false);
|
||||
}, []);
|
||||
|
||||
// Tour: auto-wiggle effect for show_hatch_card and glowing_waiting_click states
|
||||
const shouldAutoWiggle = tourVisualState === 'show_hatch_card' || tourVisualState === 'glowing_waiting_click';
|
||||
const autoWiggleTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!shouldAutoWiggle) {
|
||||
if (autoWiggleTimerRef.current) {
|
||||
clearInterval(autoWiggleTimerRef.current);
|
||||
autoWiggleTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Trigger an immediate wiggle, then repeat every 2.5s
|
||||
setIsTapWiggling(true);
|
||||
autoWiggleTimerRef.current = setInterval(() => {
|
||||
setIsTapWiggling((prev) => {
|
||||
if (!prev) return true;
|
||||
return prev;
|
||||
});
|
||||
}, 2500);
|
||||
return () => {
|
||||
if (autoWiggleTimerRef.current) {
|
||||
clearInterval(autoWiggleTimerRef.current);
|
||||
autoWiggleTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shouldAutoWiggle]);
|
||||
|
||||
// Tour: whether the egg should show crack overlay
|
||||
// The crack stays visible during 'opening' so the shell fades out WITH its cracks intact.
|
||||
// Only 'idle' and 'hatching' (shell already gone) hide the crack.
|
||||
const tourShowCrack = tourVisualState !== 'idle' && tourVisualState !== 'hatching';
|
||||
|
||||
// Tour: crack intensity level (0 = small center crack, 1-3 = progressively expanding)
|
||||
// Level 0: small central crack (show_hatch_card, glowing_waiting_click)
|
||||
// Level 1: crack expands left/right with small branches (crack_stage_1)
|
||||
// Level 2: crack expands further toward edges, more branches (crack_stage_2)
|
||||
// Level 3: crack reaches near shell edges, about to split (crack_stage_3, opening)
|
||||
const tourCrackLevel = tourVisualState === 'crack_stage_1' ? 1
|
||||
: tourVisualState === 'crack_stage_2' ? 2
|
||||
: (tourVisualState === 'crack_stage_3' || tourVisualState === 'opening') ? 3
|
||||
: 0;
|
||||
|
||||
// Divine color constants
|
||||
const DIVINE_PRIMARY_GREEN = '#55C4A2';
|
||||
const _DIVINE_HIGHLIGHT_GREEN = '#7AD9B9';
|
||||
@@ -440,18 +517,32 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Glow effect based on warmth - relative sizing */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full blur-xl transition-all duration-1000',
|
||||
animated && 'animate-pulse'
|
||||
)}
|
||||
style={{
|
||||
width: '120%',
|
||||
height: '120%',
|
||||
background: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const isGlowingTour = tourVisualState === 'glowing_waiting_click'
|
||||
|| tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2'
|
||||
|| tourVisualState === 'crack_stage_3';
|
||||
const isHatchLight = tourVisualState === 'opening' || tourVisualState === 'hatching';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full blur-xl transition-all duration-1000',
|
||||
animated && !isGlowingTour && !isHatchLight && 'animate-pulse',
|
||||
isGlowingTour && 'animate-egg-tour-glow',
|
||||
isHatchLight && 'animate-egg-tour-glow',
|
||||
)}
|
||||
style={{
|
||||
width: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
|
||||
height: isHatchLight ? '200%' : isGlowingTour ? '150%' : '120%',
|
||||
background: isHatchLight
|
||||
? `radial-gradient(circle, #fff 0%, ${glowColor} 40%, transparent 70%)`
|
||||
: isGlowingTour
|
||||
? `radial-gradient(circle, ${glowColor} 0%, ${glowColor}80 30%, transparent 70%)`
|
||||
: `radial-gradient(circle, ${glowColor} 0%, transparent 70%)`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Main egg shape - uses percentage-based sizing */}
|
||||
<div
|
||||
@@ -468,8 +559,12 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
!isTapWiggling && reaction === 'singing' && 'animate-egg-bounce',
|
||||
// Warmth effect only when animated AND warm
|
||||
animated && actualWarmth > 60 && 'animate-egg-warmth',
|
||||
// Cracking overrides other animations
|
||||
cracking && 'animate-egg-crack'
|
||||
// Cracking overrides other animations (legacy prop or tour crack stages)
|
||||
// During 'opening' the shell runs its own open animation, so suppress the shake
|
||||
(cracking || (tourCrackLevel >= 1 && tourVisualState !== 'opening')) && 'animate-egg-crack',
|
||||
// Opening/hatching: fade out the egg shell (crack overlay stays inside and fades with it)
|
||||
tourVisualState === 'opening' && 'animate-egg-tour-open',
|
||||
tourVisualState === 'hatching' && 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: '80%',
|
||||
@@ -480,7 +575,7 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
inset -0.5em -0.5em 1em ${shadow}33,
|
||||
inset 0.5em 0.5em 1em ${highlight}26
|
||||
`,
|
||||
filter: cracking ? 'brightness(1.1)' : 'brightness(1)',
|
||||
filter: (cracking || tourCrackLevel >= 1) ? 'brightness(1.1)' : 'brightness(1)',
|
||||
}}
|
||||
>
|
||||
{/* Highlight on the egg - uses color variants instead of white */}
|
||||
@@ -538,133 +633,181 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
renderLegacySpecialMark(effectiveSpecialMark)
|
||||
))}
|
||||
|
||||
{/* Crack pattern based on docs/aprovado.svg when cracking is true */}
|
||||
{cracking && (
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none w-full h-full"
|
||||
viewBox="0 0 120 125"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Main horizontal crack (adapted from aprovado.svg) */}
|
||||
<path
|
||||
d="M10 62
|
||||
L20 60
|
||||
L30 64
|
||||
L40 59
|
||||
L50 65
|
||||
L60 58
|
||||
L70 66
|
||||
L80 57
|
||||
L90 67
|
||||
L100 59
|
||||
L110 65"
|
||||
stroke="rgba(0, 0, 0, 0.6)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Crack pattern - stage-specific paths that grow outward from center */}
|
||||
{(cracking || tourShowCrack) && (() => {
|
||||
// Legacy cracking shows full crack; tour uses progressive stage-specific paths
|
||||
const level = cracking ? 3 : tourCrackLevel;
|
||||
return (
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none w-full h-full transition-opacity duration-300"
|
||||
viewBox="0 0 120 125"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{/*
|
||||
Stage-specific crack paths.
|
||||
Each level has its OWN distinct paths that expand outward from the egg center.
|
||||
The crack grows from a small central cluster to full-width fracture.
|
||||
|
||||
{/* Secondary cracks (adapted from aprovado.svg) */}
|
||||
<path
|
||||
d="M30 64 L28 70"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M50 65 L53 71"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 58 L57 52"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M80 57 L82 50"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90 67 L95 72"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M100 59 L97 53"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M110 65 L113 69"
|
||||
stroke="rgba(0, 0, 0, 0.4)"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
Viewbox center is roughly (60, 62).
|
||||
Level 0: tiny central crack (~3-4 small connected segments near center)
|
||||
Level 1: extends left/right from center, first branches
|
||||
Level 2: reaches further toward edges, more fracture detail
|
||||
Level 3: crack reaches near shell edges, dense branching
|
||||
*/}
|
||||
|
||||
{/* Additional micro-cracks for detail */}
|
||||
<path
|
||||
d="M40 59 L38 55"
|
||||
stroke="rgba(0, 0, 0, 0.25)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M70 66 L73 70"
|
||||
stroke="rgba(0, 0, 0, 0.25)"
|
||||
strokeWidth="0.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 60 L18 56"
|
||||
stroke="rgba(0, 0, 0, 0.2)"
|
||||
strokeWidth="0.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* ── Level 0: Small central crack ── */}
|
||||
{/* A few short connected segments clustered around the center of the egg */}
|
||||
{level === 0 && (<>
|
||||
{/* Main tiny crack: ~15px wide, centered */}
|
||||
<path
|
||||
d="M53 63 L57 60 L63 64 L67 61"
|
||||
stroke="rgba(0, 0, 0, 0.5)"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Tiny upward branch from center */}
|
||||
<path
|
||||
d="M57 60 L56 57"
|
||||
stroke="rgba(0, 0, 0, 0.35)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Tiny downward branch */}
|
||||
<path
|
||||
d="M63 64 L65 67"
|
||||
stroke="rgba(0, 0, 0, 0.35)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Subtle highlight alongside main crack */}
|
||||
<path
|
||||
d="M54 64 L58 61 L64 65"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>)}
|
||||
|
||||
{/* Crack highlights for depth (following the main crack pattern) */}
|
||||
<path
|
||||
d="M10 63
|
||||
L20 61
|
||||
L30 65
|
||||
L40 60
|
||||
L50 66
|
||||
L60 59
|
||||
L70 67
|
||||
L80 58
|
||||
L90 68
|
||||
L100 60
|
||||
L110 66"
|
||||
stroke="rgba(255, 255, 255, 0.15)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* ── Level 1: Medium crack expanding from center ── */}
|
||||
{/* Crack extends ~30px wide, first real branches appear */}
|
||||
{level === 1 && (<>
|
||||
{/* Main crack: wider than level 0, extends left and right */}
|
||||
<path
|
||||
d="M42 61 L48 64 L53 60 L60 65 L67 59 L73 63 L78 60"
|
||||
stroke="rgba(0, 0, 0, 0.55)"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M43 62 L49 65 L54 61 L61 66 L68 60 L74 64"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Branch: upward left */}
|
||||
<path d="M48 64 L46 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branch: upward from center-right */}
|
||||
<path d="M67 59 L65 54" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branch: downward right */}
|
||||
<path d="M73 63 L76 68" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Small micro-branch */}
|
||||
<path d="M53 60 L51 56" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
</>)}
|
||||
|
||||
{/* Secondary crack highlights */}
|
||||
<path
|
||||
d="M30 65 L28 71"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 59 L57 53"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth="0.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* ── Level 2: Larger crack reaching toward sides ── */}
|
||||
{/* Crack extends ~60px wide, more branching detail */}
|
||||
{level === 2 && (<>
|
||||
{/* Main crack: extends well toward both sides */}
|
||||
<path
|
||||
d="M30 63 L37 60 L44 65 L52 59 L60 64 L68 58 L76 63 L83 59 L90 64"
|
||||
stroke="rgba(0, 0, 0, 0.6)"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M31 64 L38 61 L45 66 L53 60 L61 65 L69 59 L77 64 L84 60"
|
||||
stroke="rgba(255, 255, 255, 0.12)"
|
||||
strokeWidth="0.7"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Branches: left side */}
|
||||
<path d="M37 60 L34 55" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
<path d="M44 65 L41 71" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branches: center */}
|
||||
<path d="M52 59 L50 53" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
<path d="M60 64 L63 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
{/* Branches: right side */}
|
||||
<path d="M68 58 L66 52" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
<path d="M76 63 L79 69" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1" strokeLinecap="round" />
|
||||
<path d="M83 59 L86 54" stroke="rgba(0, 0, 0, 0.35)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Micro-cracks */}
|
||||
<path d="M50 53 L48 50" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M63 70 L66 73" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
</>)}
|
||||
|
||||
{/* ── Level 3: Full crack reaching shell edges ── */}
|
||||
{/* Crack spans nearly the full width, dense fracture network */}
|
||||
{level >= 3 && (<>
|
||||
{/* Main crack: nearly full width of egg */}
|
||||
<path
|
||||
d="M15 62 L23 59 L32 64 L40 58 L50 65 L60 57 L70 64 L80 58 L88 63 L96 59 L105 64"
|
||||
stroke="rgba(0, 0, 0, 0.65)"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Highlight */}
|
||||
<path
|
||||
d="M16 63 L24 60 L33 65 L41 59 L51 66 L61 58 L71 65 L81 59 L89 64 L97 60"
|
||||
stroke="rgba(255, 255, 255, 0.13)"
|
||||
strokeWidth="0.8"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Heavy branches: left region */}
|
||||
<path d="M23 59 L19 53" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M32 64 L28 72" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M28 72 L25 76" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.9" strokeLinecap="round" />
|
||||
{/* Heavy branches: center-left */}
|
||||
<path d="M40 58 L37 51" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M50 65 L47 73" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M37 51 L35 47" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
|
||||
{/* Heavy branches: center */}
|
||||
<path d="M60 57 L58 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M60 57 L63 68" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
{/* Heavy branches: center-right */}
|
||||
<path d="M70 64 L73 71" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M80 58 L83 50" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.3" strokeLinecap="round" />
|
||||
<path d="M83 50 L86 46" stroke="rgba(0, 0, 0, 0.3)" strokeWidth="0.8" strokeLinecap="round" />
|
||||
{/* Heavy branches: right region */}
|
||||
<path d="M88 63 L91 70" stroke="rgba(0, 0, 0, 0.45)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M96 59 L99 52" stroke="rgba(0, 0, 0, 0.5)" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<path d="M105 64 L109 70" stroke="rgba(0, 0, 0, 0.4)" strokeWidth="1.1" strokeLinecap="round" />
|
||||
{/* Micro-cracks (tertiary detail) */}
|
||||
<path d="M47 73 L44 77" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M73 71 L76 75" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M58 50 L55 46" stroke="rgba(0, 0, 0, 0.25)" strokeWidth="0.7" strokeLinecap="round" />
|
||||
<path d="M19 53 L17 49" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
|
||||
<path d="M99 52 L102 48" stroke="rgba(0, 0, 0, 0.2)" strokeWidth="0.6" strokeLinecap="round" />
|
||||
</>)}
|
||||
</svg>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Title display for special eggs */}
|
||||
{blobbi?.title && (
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import './styles/egg-animations.css';
|
||||
|
||||
// Components
|
||||
export { EggGraphic, type EggReactionState, type EggStatusEffects } from './components/EggGraphic';
|
||||
export { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from './components/EggGraphic';
|
||||
export { SpecialMarkRenderer, SpecialMarkFallback } from './components/SpecialMarkRenderer';
|
||||
|
||||
// Hooks
|
||||
|
||||
@@ -320,6 +320,49 @@
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Tour Visual State Animations
|
||||
========================================== */
|
||||
|
||||
/* Shell opening: scale up slightly then fade out with blur */
|
||||
@keyframes egg-tour-open {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
filter: brightness(1.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0;
|
||||
filter: brightness(2) blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-tour-open {
|
||||
animation: egg-tour-open 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Pulsing glow for the "waiting for click" tour state */
|
||||
@keyframes egg-tour-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-tour-glow {
|
||||
animation: egg-tour-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Responsive adjustments
|
||||
========================================== */
|
||||
@@ -351,7 +394,9 @@
|
||||
.animate-egg-sweat-drop,
|
||||
.animate-egg-dust-particle,
|
||||
.animate-egg-spiral,
|
||||
.animate-egg-sparkle {
|
||||
.animate-egg-sparkle,
|
||||
.animate-egg-tour-glow,
|
||||
.animate-egg-tour-open {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -393,3 +438,354 @@
|
||||
filter: grayscale(1) contrast(1.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Onboarding Hatching Ceremony Animations
|
||||
========================================== */
|
||||
|
||||
/* Soft breathing pulse for the egg before interaction */
|
||||
@keyframes egg-onboard-breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: brightness(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015);
|
||||
filter: brightness(1.03) drop-shadow(0 0 30px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-breathe {
|
||||
animation: egg-onboard-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Screen-filling radial glow that expands from center on hatch */
|
||||
@keyframes onboard-glow-expand {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-expand {
|
||||
animation: onboard-glow-expand 1.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle lingering glow fade after hatch - holds then fades */
|
||||
@keyframes onboard-glow-linger {
|
||||
0% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-linger {
|
||||
animation: onboard-glow-linger 7s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Sentimental text fade in - very slow, dreamlike */
|
||||
@keyframes onboard-text-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-text-reveal {
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Delayed text reveal for secondary text */
|
||||
.animate-onboard-text-reveal-delay {
|
||||
opacity: 0;
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
/* Soft fade out for transition between phases */
|
||||
@keyframes onboard-soft-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-out {
|
||||
animation: onboard-soft-fade-out 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Soft fade in */
|
||||
@keyframes onboard-soft-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-in {
|
||||
animation: onboard-soft-fade-in 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Floating particles that drift upward from the egg */
|
||||
@keyframes onboard-particle-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.5);
|
||||
}
|
||||
20% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-120px) scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle twinkle - stays in place, pulses brightness */
|
||||
@keyframes onboard-sparkle-twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle drift - gentle floating motion */
|
||||
@keyframes onboard-sparkle-drift {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.3);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-8px) scale(1);
|
||||
}
|
||||
80% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-25px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px) scale(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Egg entrance - subtle float up from darkness */
|
||||
@keyframes egg-onboard-entrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-entrance {
|
||||
animation: egg-onboard-entrance 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Egg shake intensifying - for crack stages */
|
||||
@keyframes egg-onboard-shake-light {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
25% { transform: translateX(-3px) rotate(-2deg); }
|
||||
75% { transform: translateX(3px) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-medium {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
20% { transform: translateX(-5px) rotate(-3deg); }
|
||||
40% { transform: translateX(4px) rotate(2deg); }
|
||||
60% { transform: translateX(-4px) rotate(-2deg); }
|
||||
80% { transform: translateX(5px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-heavy {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
10% { transform: translateX(-6px) rotate(-4deg); }
|
||||
20% { transform: translateX(5px) rotate(3deg); }
|
||||
30% { transform: translateX(-7px) rotate(-3deg); }
|
||||
40% { transform: translateX(6px) rotate(4deg); }
|
||||
50% { transform: translateX(-5px) rotate(-2deg); }
|
||||
60% { transform: translateX(7px) rotate(3deg); }
|
||||
70% { transform: translateX(-6px) rotate(-4deg); }
|
||||
80% { transform: translateX(5px) rotate(2deg); }
|
||||
90% { transform: translateX(-4px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-light {
|
||||
animation: egg-onboard-shake-light 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-medium {
|
||||
animation: egg-onboard-shake-medium 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-heavy {
|
||||
animation: egg-onboard-shake-heavy 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* Final burst - egg explodes into light */
|
||||
@keyframes egg-onboard-burst {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
filter: brightness(2.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0;
|
||||
filter: brightness(4) blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-burst {
|
||||
animation: egg-onboard-burst 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Screen flash on hatch */
|
||||
@keyframes onboard-screen-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-screen-flash {
|
||||
animation: onboard-screen-flash 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle continue prompt pulse */
|
||||
@keyframes onboard-continue-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-continue-pulse {
|
||||
animation: onboard-continue-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slow rotating golden incandescence behind hatched blobbi */
|
||||
@keyframes onboard-golden-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(90deg) scale(1.06);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(270deg) scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-rotate {
|
||||
animation: onboard-golden-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Golden glow fade-in */
|
||||
@keyframes onboard-golden-fadein {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion overrides for onboarding */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-egg-onboard-breathe,
|
||||
.animate-onboard-glow-expand,
|
||||
.animate-onboard-glow-linger,
|
||||
.animate-onboard-text-reveal,
|
||||
.animate-onboard-text-reveal-delay,
|
||||
.animate-onboard-soft-fade-out,
|
||||
.animate-onboard-soft-fade-in,
|
||||
.animate-egg-onboard-entrance,
|
||||
.animate-egg-onboard-shake-light,
|
||||
.animate-egg-onboard-shake-medium,
|
||||
.animate-egg-onboard-shake-heavy,
|
||||
.animate-egg-onboard-burst,
|
||||
.animate-onboard-screen-flash,
|
||||
.animate-onboard-continue-pulse,
|
||||
.animate-onboard-golden-rotate,
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* BlobbiHatchingCeremony - Immersive hatching experience for every new egg
|
||||
*
|
||||
* Flow:
|
||||
* 1. Dark screen, egg silently created in background
|
||||
* 2. Huge breathing egg appears. No text. No UI.
|
||||
* 3. Click egg 4 times through crack stages with intensifying shakes
|
||||
* 4. Final click -> egg bursts into light, actual hatch mutation fires
|
||||
* 5. Flash clears -> hatched baby blobbi revealed center screen with glow/sparkles
|
||||
* 6. Typewriter dialog appears below blobbi (click to complete line / advance)
|
||||
* 7. Naming prompt, then ceremony complete
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
INITIAL_BLOBBONAUT_COINS,
|
||||
STAT_MAX,
|
||||
buildBlobbonautTags,
|
||||
updateBlobbonautTags,
|
||||
updateBlobbiTags,
|
||||
type BlobbonautProfile,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
generateEggPreview,
|
||||
previewToEventTags,
|
||||
previewToBlobbiCompanion,
|
||||
type BlobbiEggPreview,
|
||||
} from '../lib/blobbi-preview';
|
||||
|
||||
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BIRTH_DIALOG: string[] = [
|
||||
'Something stirs...',
|
||||
'A tiny life has chosen you. It knows only warmth, and your presence.',
|
||||
];
|
||||
|
||||
const NAMING_DIALOG = 'Every life deserves a name.\nWhat will you call this one?';
|
||||
|
||||
// ─── Phase Machine ────────────────────────────────────────────────────────────
|
||||
|
||||
type CeremonyPhase =
|
||||
| 'loading'
|
||||
| 'egg'
|
||||
| 'crack_1'
|
||||
| 'crack_2'
|
||||
| 'crack_3'
|
||||
| 'hatching' // egg burst + hatch mutation
|
||||
| 'reveal' // flash clearing, baby blobbi fading in with glow
|
||||
| 'dialog' // typewriter dialog lines
|
||||
| 'naming'
|
||||
| 'complete';
|
||||
|
||||
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
function useTypewriter(fullText: string, active: boolean, speed = 35) {
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const indexRef = useRef(0);
|
||||
|
||||
// Reset when text changes
|
||||
useEffect(() => {
|
||||
setDisplayed('');
|
||||
setDone(false);
|
||||
indexRef.current = 0;
|
||||
}, [fullText]);
|
||||
|
||||
// Run typewriter
|
||||
useEffect(() => {
|
||||
if (!active || done) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
indexRef.current++;
|
||||
const next = fullText.slice(0, indexRef.current);
|
||||
setDisplayed(next);
|
||||
if (indexRef.current >= fullText.length) {
|
||||
setDone(true);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
}, speed);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [active, done, fullText, speed]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
setDisplayed(fullText);
|
||||
setDone(true);
|
||||
}, [fullText]);
|
||||
|
||||
return { displayed, done, complete };
|
||||
}
|
||||
|
||||
// Module-level guard: prevents duplicate egg creation if the component remounts
|
||||
// (e.g. React strict mode, parent re-render causing unmount/remount).
|
||||
// Tracks pubkeys that have already started setup in this browser session.
|
||||
const setupInFlightFor = new Set<string>();
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiHatchingCeremonyProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
onComplete?: () => void;
|
||||
/** If provided, skip egg creation and start from the cracking phase with this existing egg. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/** If true, only create the egg and skip the hatching ceremony. The egg stays an egg. */
|
||||
eggOnly?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiHatchingCeremony({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
existingCompanion,
|
||||
eggOnly = false,
|
||||
}: BlobbiHatchingCeremonyProps) {
|
||||
const isExistingEgg = !!existingCompanion;
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: authorData } = useAuthor(user?.pubkey);
|
||||
|
||||
// ── Core state ──
|
||||
const [phase, setPhase] = useState<CeremonyPhase>('loading');
|
||||
const [preview, setPreview] = useState<BlobbiEggPreview | null>(null);
|
||||
const [name, setName] = useState(existingCompanion?.name ?? '');
|
||||
const [isNaming, setIsNaming] = useState(false);
|
||||
const [eggVisible, setEggVisible] = useState(false);
|
||||
|
||||
// Reveal phase state
|
||||
const [blobbiVisible, setBlobbiVisible] = useState(false);
|
||||
const [showFlash, setShowFlash] = useState(false);
|
||||
const [, setShowRevealGlow] = useState(false);
|
||||
const [fadeOut, setFadeOut] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [dialogLineIndex, setDialogLineIndex] = useState(0);
|
||||
const [dialogActive, setDialogActive] = useState(false);
|
||||
const [namingVisible, setNamingVisible] = useState(false);
|
||||
|
||||
// Refs
|
||||
const setupAttempted = useRef(false);
|
||||
const profileRef = useRef(profile);
|
||||
profileRef.current = profile;
|
||||
const previewRef = useRef(preview);
|
||||
previewRef.current = preview;
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const eggContainerRef = useRef<HTMLDivElement>(null);
|
||||
const entrancePlayed = useRef(false);
|
||||
const eggTagsRef = useRef<string[][] | null>(null);
|
||||
|
||||
// ── Companion visuals ──
|
||||
const eggCompanion = useMemo(
|
||||
() => preview ? previewToBlobbiCompanion(preview) : null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[preview?.d],
|
||||
);
|
||||
|
||||
// Baby companion (same visual data but stage=baby)
|
||||
const babyCompanion = useMemo((): BlobbiCompanion | null => {
|
||||
if (!eggCompanion) return null;
|
||||
return { ...eggCompanion, stage: 'baby', state: 'active' };
|
||||
}, [eggCompanion]);
|
||||
|
||||
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
|
||||
|
||||
// ── Typewriter for current dialog line ──
|
||||
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
|
||||
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
|
||||
|
||||
const namingTypewriter = useTypewriter(NAMING_DIALOG, namingVisible);
|
||||
|
||||
// ── Fast-path setup for existing eggs (no publishing needed) ──
|
||||
useEffect(() => {
|
||||
if (!isExistingEgg || setupAttempted.current || !existingCompanion) return;
|
||||
setupAttempted.current = true;
|
||||
|
||||
// Build a minimal preview from the existing companion
|
||||
const fakePreview: BlobbiEggPreview = {
|
||||
d: existingCompanion.d,
|
||||
petId: existingCompanion.d,
|
||||
ownerPubkey: user?.pubkey ?? '',
|
||||
name: existingCompanion.name,
|
||||
stage: 'egg',
|
||||
state: 'active',
|
||||
seed: existingCompanion.seed ?? '',
|
||||
stats: {
|
||||
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
|
||||
happiness: existingCompanion.stats.happiness ?? STAT_MAX,
|
||||
health: existingCompanion.stats.health ?? STAT_MAX,
|
||||
hygiene: existingCompanion.stats.hygiene ?? STAT_MAX,
|
||||
energy: existingCompanion.stats.energy ?? STAT_MAX,
|
||||
},
|
||||
visualTraits: existingCompanion.visualTraits,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
setPreview(fakePreview);
|
||||
previewRef.current = fakePreview;
|
||||
eggTagsRef.current = existingCompanion.allTags;
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isExistingEgg, existingCompanion?.d]);
|
||||
|
||||
// ── Silent setup: create profile + egg (new egg flow only) ──
|
||||
useEffect(() => {
|
||||
if (isExistingEgg) return; // Skip for existing eggs
|
||||
if (setupAttempted.current || !user?.pubkey) return;
|
||||
// Module-level guard: if another mount already started setup for this pubkey, skip
|
||||
if (setupInFlightFor.has(user.pubkey)) return;
|
||||
setupAttempted.current = true;
|
||||
setupInFlightFor.add(user.pubkey);
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const currentProfile = profileRef.current;
|
||||
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
|
||||
|
||||
// 1. Create profile if needed
|
||||
if (!currentProfile) {
|
||||
const suggestedName =
|
||||
authorData?.metadata?.display_name ||
|
||||
authorData?.metadata?.name ||
|
||||
'Blobbonaut';
|
||||
|
||||
const baseTags = buildBlobbonautTags(user.pubkey);
|
||||
const tagsWithName = [
|
||||
...baseTags,
|
||||
['name', suggestedName],
|
||||
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
|
||||
];
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: tagsWithName,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
invalidateProfile();
|
||||
latestProfileTags = tagsWithName;
|
||||
}
|
||||
|
||||
// 2. Generate and publish egg
|
||||
const eggPreview = generateEggPreview(user.pubkey, 'Egg');
|
||||
setPreview(eggPreview);
|
||||
previewRef.current = eggPreview;
|
||||
|
||||
const eggTags = previewToEventTags(eggPreview);
|
||||
eggTagsRef.current = eggTags;
|
||||
|
||||
const eggEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: 'A new Blobbi egg!',
|
||||
tags: eggTags,
|
||||
created_at: eggPreview.createdAt,
|
||||
});
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 3. Update profile with has[] entry
|
||||
if (latestProfileTags) {
|
||||
const existingHas = latestProfileTags
|
||||
.filter(([k]) => k === 'has')
|
||||
.map(([, v]) => v);
|
||||
const newHas = [...existingHas, eggPreview.d];
|
||||
|
||||
const updatedTags = updateBlobbonautTags(latestProfileTags, {
|
||||
has: newHas,
|
||||
});
|
||||
|
||||
const updatedProfileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(updatedProfileEvent);
|
||||
}
|
||||
|
||||
setStoredSelectedD(eggPreview.d);
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Setup failed:', error);
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Failed to set up your Blobbi. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
// Clear module-level guard so future adoptions can create new eggs
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(setup, 600);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// If the timer was cleared before setup ran, release the guard
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) profileRef.current = profile;
|
||||
}, [profile]);
|
||||
|
||||
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
|
||||
useEffect(() => {
|
||||
if (!eggOnly || !eggVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eggOnly, eggVisible, onComplete]);
|
||||
|
||||
// Play entrance animation once
|
||||
useEffect(() => {
|
||||
if (eggVisible && !entrancePlayed.current && eggContainerRef.current) {
|
||||
entrancePlayed.current = true;
|
||||
const el = eggContainerRef.current;
|
||||
el.classList.add('animate-egg-onboard-entrance');
|
||||
const onEnd = () => {
|
||||
el.classList.remove('animate-egg-onboard-entrance');
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
}
|
||||
}, [eggVisible]);
|
||||
|
||||
// ── Shake (DOM-only, no re-render) ──
|
||||
const triggerShake = useCallback((cls: string) => {
|
||||
const el = eggContainerRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove(
|
||||
'animate-egg-onboard-shake-light',
|
||||
'animate-egg-onboard-shake-medium',
|
||||
'animate-egg-onboard-shake-heavy',
|
||||
);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(cls);
|
||||
}, []);
|
||||
|
||||
// ── Execute the actual hatch: egg -> baby ──
|
||||
const executeHatch = useCallback(async () => {
|
||||
const tags = eggTagsRef.current;
|
||||
if (!tags) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const babyTags = updateBlobbiTags(tags, {
|
||||
stage: 'baby',
|
||||
state: 'active',
|
||||
hunger: STAT_MAX.toString(),
|
||||
happiness: STAT_MAX.toString(),
|
||||
health: STAT_MAX.toString(),
|
||||
hygiene: STAT_MAX.toString(),
|
||||
energy: STAT_MAX.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const babyName = previewRef.current?.name ?? 'Egg';
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${babyName} is a baby Blobbi.`,
|
||||
tags: babyTags,
|
||||
});
|
||||
|
||||
eggTagsRef.current = babyTags;
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
}, [publishEvent, updateCompanionEvent, invalidateCompanion]);
|
||||
|
||||
// ── Egg click ──
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (phase === 'egg') {
|
||||
triggerShake('animate-egg-onboard-shake-light');
|
||||
setPhase('crack_1');
|
||||
} else if (phase === 'crack_1') {
|
||||
triggerShake('animate-egg-onboard-shake-medium');
|
||||
setPhase('crack_2');
|
||||
} else if (phase === 'crack_2') {
|
||||
triggerShake('animate-egg-onboard-shake-heavy');
|
||||
setPhase('crack_3');
|
||||
} else if (phase === 'crack_3') {
|
||||
// Final click -> hatch!
|
||||
setPhase('hatching');
|
||||
setShowFlash(true);
|
||||
|
||||
// Fire the actual hatch mutation
|
||||
executeHatch().catch(console.error);
|
||||
|
||||
// After flash, reveal the baby
|
||||
setTimeout(() => {
|
||||
setShowFlash(false);
|
||||
setShowRevealGlow(true);
|
||||
setPhase('reveal');
|
||||
|
||||
// Fade in blobbi
|
||||
setTimeout(() => setBlobbiVisible(true), 400);
|
||||
|
||||
// After blobbi settles, start dialog
|
||||
setTimeout(() => {
|
||||
setPhase('dialog');
|
||||
setDialogLineIndex(0);
|
||||
setDialogActive(true);
|
||||
}, 2200);
|
||||
}, 1400);
|
||||
}
|
||||
}, [phase, triggerShake, executeHatch]);
|
||||
|
||||
// ── Dialog click: complete line or advance ──
|
||||
const handleDialogClick = useCallback(() => {
|
||||
if (phase !== 'dialog') return;
|
||||
|
||||
if (!dialogTypewriter.done) {
|
||||
// Complete the current line instantly
|
||||
dialogTypewriter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to next line
|
||||
const nextIndex = dialogLineIndex + 1;
|
||||
if (nextIndex < BIRTH_DIALOG.length) {
|
||||
setDialogActive(false);
|
||||
setDialogLineIndex(nextIndex);
|
||||
// Small pause before next line starts
|
||||
setTimeout(() => setDialogActive(true), 150);
|
||||
} else {
|
||||
// All lines done -> naming
|
||||
setDialogActive(false);
|
||||
setTimeout(() => {
|
||||
setPhase('naming');
|
||||
setTimeout(() => {
|
||||
setNamingVisible(true);
|
||||
setTimeout(() => nameInputRef.current?.focus(), 600);
|
||||
}, 200);
|
||||
}, 400);
|
||||
}
|
||||
}, [phase, dialogTypewriter, dialogLineIndex]);
|
||||
|
||||
// ── Complete ceremony ──
|
||||
const completeCeremony = useCallback(async (finalName: string) => {
|
||||
try {
|
||||
// Update egg/baby name if changed
|
||||
const currentTags = eggTagsRef.current;
|
||||
if (currentTags && finalName !== (previewRef.current?.name ?? 'Egg')) {
|
||||
const namedTags = updateBlobbiTags(currentTags, { name: finalName });
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${finalName} is a baby Blobbi.`,
|
||||
tags: namedTags,
|
||||
});
|
||||
updateCompanionEvent(event);
|
||||
}
|
||||
|
||||
// Mark onboarding done
|
||||
const currentProfile = profileRef.current;
|
||||
if (currentProfile) {
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Failed to persist completion:', error);
|
||||
}
|
||||
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
|
||||
|
||||
// ── Naming submit ──
|
||||
const handleNameSubmit = useCallback(async () => {
|
||||
if (isNaming || !name.trim()) return;
|
||||
setIsNaming(true);
|
||||
|
||||
try {
|
||||
await completeCeremony(name.trim());
|
||||
setNamingVisible(false);
|
||||
// Fade to white, then complete
|
||||
setTimeout(() => {
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Naming failed:', error);
|
||||
toast({
|
||||
title: 'Failed to save name',
|
||||
description: 'Your Blobbi was created, but the name could not be saved.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
} finally {
|
||||
setIsNaming(false);
|
||||
}
|
||||
}, [name, isNaming, completeCeremony, onComplete]);
|
||||
|
||||
// ── Tour visual state for EggGraphic crack rendering ──
|
||||
const tourVisualState = useMemo(() => {
|
||||
switch (phase) {
|
||||
case 'crack_1': return 'crack_stage_1' as const;
|
||||
case 'crack_2': return 'crack_stage_2' as const;
|
||||
case 'crack_3': return 'crack_stage_3' as const;
|
||||
case 'hatching': return 'opening' as const;
|
||||
default: return 'idle' as const;
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const isEggPhase = phase === 'egg' || phase === 'crack_1' || phase === 'crack_2' || phase === 'crack_3';
|
||||
const isHatching = phase === 'hatching';
|
||||
const showBaby = phase === 'reveal' || phase === 'dialog' || phase === 'naming';
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)' }}
|
||||
>
|
||||
<div
|
||||
className="absolute size-32 rounded-full opacity-20 animate-pulse"
|
||||
style={{ background: `radial-gradient(circle, ${eggColor}40 0%, transparent 70%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-hidden select-none"
|
||||
style={{
|
||||
background: showBaby
|
||||
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
|
||||
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
|
||||
transition: 'background 2s ease-out',
|
||||
}}
|
||||
onClick={phase === 'dialog' ? handleDialogClick : undefined}
|
||||
>
|
||||
{/* ── Ambient background glow (egg phase only) ── */}
|
||||
{!showBaby && (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity"
|
||||
style={{
|
||||
transitionDuration: '3000ms',
|
||||
background: `radial-gradient(ellipse at 50% 50%, ${eggColor}30 0%, transparent 60%)`,
|
||||
opacity: (isEggPhase || isHatching) ? 0.07 : 0.05,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Floating particles (egg phase) ── */}
|
||||
{isEggPhase && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 2 + (i % 3),
|
||||
height: 2 + (i % 3),
|
||||
left: `${20 + (i * 12) % 60}%`,
|
||||
bottom: '40%',
|
||||
backgroundColor: `${eggColor}40`,
|
||||
animation: `onboard-particle-rise ${4 + i * 0.7}s ease-out ${i * 0.8}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The Egg ── */}
|
||||
{(isEggPhase || isHatching) && eggCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
ref={eggContainerRef}
|
||||
className={cn(
|
||||
'cursor-pointer relative',
|
||||
eggVisible ? '' : 'opacity-0',
|
||||
eggVisible && isEggPhase && 'animate-egg-onboard-breathe',
|
||||
isHatching && 'animate-egg-onboard-burst',
|
||||
)}
|
||||
onClick={isEggPhase ? handleEggClick : undefined}
|
||||
>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-full blur-2xl transition-opacity duration-1000"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${eggColor}50 0%, transparent 70%)`,
|
||||
opacity: phase === 'crack_3' ? 0.5 : phase === 'crack_2' ? 0.35 : phase === 'crack_1' ? 0.25 : 0.15,
|
||||
}}
|
||||
/>
|
||||
<BlobbiStageVisual
|
||||
companion={eggCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-56 sm:size-64 md:size-72"
|
||||
tourVisualState={tourVisualState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Screen flash ── */}
|
||||
{showFlash && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white animate-onboard-screen-flash pointer-events-none"
|
||||
style={{ zIndex: 80 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Hatched baby blobbi with golden incandescence ── */}
|
||||
{showBaby && babyCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
style={{ paddingBottom: '18%' }}
|
||||
>
|
||||
{/* Rotating golden incandescence */}
|
||||
<div className={cn(
|
||||
'absolute animate-onboard-golden-fadein',
|
||||
blobbiVisible ? '' : 'opacity-0',
|
||||
)}>
|
||||
<div
|
||||
className="animate-onboard-golden-rotate"
|
||||
style={{
|
||||
width: 900,
|
||||
height: 900,
|
||||
background: `conic-gradient(
|
||||
from 0deg,
|
||||
rgba(255, 250, 230, 0.18) 0deg,
|
||||
rgba(255, 245, 210, 0.50) 50deg,
|
||||
rgba(255, 250, 235, 0.22) 100deg,
|
||||
rgba(255, 248, 220, 0.15) 150deg,
|
||||
rgba(255, 245, 210, 0.48) 210deg,
|
||||
rgba(255, 250, 230, 0.20) 270deg,
|
||||
rgba(255, 248, 220, 0.15) 320deg,
|
||||
rgba(255, 250, 230, 0.18) 360deg
|
||||
)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bright white-gold shine directly behind blobbi */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 320,
|
||||
height: 320,
|
||||
background: 'radial-gradient(circle, rgba(255,255,245,0.70) 0%, rgba(255,250,225,0.30) 40%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wider golden halo */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity [transition-duration:2000ms]',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 700,
|
||||
height: 700,
|
||||
background: 'radial-gradient(circle, rgba(255, 248, 210, 0.40) 0%, rgba(255, 240, 190, 0.18) 40%, transparent 65%)',
|
||||
filter: 'blur(15px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Sparkles everywhere ── */}
|
||||
|
||||
{/* Inner ring - bright twinkling sparkles */}
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const angle = (i / 20) * Math.PI * 2;
|
||||
const r = 80 + (i % 4) * 35;
|
||||
const size = 4 + (i % 3) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`inner-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 2 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.4) 40%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,130,1) 0%, rgba(255,220,80,0.3) 50%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer ring - larger, slower sparkles */}
|
||||
{Array.from({ length: 16 }).map((_, i) => {
|
||||
const angle = (i / 16) * Math.PI * 2 + 0.3;
|
||||
const r = 170 + (i % 3) * 50;
|
||||
const size = 5 + (i % 4) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`outer-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 3 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
|
||||
: 'radial-gradient(circle, rgba(255,235,120,0.85) 0%, transparent 60%)',
|
||||
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.25}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scattered wide-field sparkles */}
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 2.7 + 1.3) * 0.5 + 0.5) * 80 + 10;
|
||||
const y = (Math.cos(i * 3.1 + 0.7) * 0.5 + 0.5) * 70 + 10;
|
||||
const size = 3 + (i % 3) * 2;
|
||||
return (
|
||||
<div
|
||||
key={`field-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
borderRadius: '50%',
|
||||
background: i % 4 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.95) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,160,0.8) 0%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${2 + (i % 7) * 0.6}s ease-in-out ${i * 0.18}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drifting light motes rising from below */}
|
||||
{Array.from({ length: 10 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 1.9) * 0.5 + 0.5) * 70 + 15;
|
||||
return (
|
||||
<div
|
||||
key={`drift-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 5 + (i % 3) * 3,
|
||||
height: 5 + (i % 3) * 3,
|
||||
left: `${x}%`,
|
||||
bottom: '20%',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(255,250,200,0.9) 0%, rgba(255,230,120,0.3) 50%, transparent 100%)',
|
||||
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.5}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The baby blobbi */}
|
||||
<div className={cn(
|
||||
'relative transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}>
|
||||
<BlobbiStageVisual
|
||||
companion={babyCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Dialog text (no box, blur behind) ── */}
|
||||
{phase === 'dialog' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className="relative max-w-md w-full text-center">
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Speaker */}
|
||||
<div className="relative">
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter text */}
|
||||
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[3em]">
|
||||
{dialogTypewriter.displayed}
|
||||
{!dialogTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Advance indicator */}
|
||||
{dialogTypewriter.done && (
|
||||
<div className="mt-4 animate-onboard-continue-pulse">
|
||||
<span className="text-xs text-white/30">▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Naming ── */}
|
||||
{phase === 'naming' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className={cn(
|
||||
'relative max-w-md w-full text-center',
|
||||
namingVisible ? 'animate-onboard-soft-fade-in' : 'opacity-0',
|
||||
)}>
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Speaker */}
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter question */}
|
||||
<p className="text-base sm:text-lg text-white/85 leading-relaxed font-light mb-6 min-h-[1.5em] whitespace-pre-line">
|
||||
{namingTypewriter.displayed}
|
||||
{!namingTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Input + confirm (appear after typewriter done) */}
|
||||
{namingTypewriter.done && (
|
||||
<div className="space-y-3 animate-onboard-soft-fade-in">
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="..."
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
className={cn(
|
||||
'text-center text-lg font-light h-12',
|
||||
'bg-white/10 border-transparent text-white placeholder:text-white/30',
|
||||
'focus:bg-white/[0.25] focus:border-transparent focus:ring-0 focus:outline-none',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'focus:shadow-[0_0_15px_rgba(255,255,255,0.15),0_0_40px_rgba(255,250,230,0.08)]',
|
||||
'transition-all duration-300',
|
||||
'rounded-full transition-shadow duration-500',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) handleNameSubmit();
|
||||
}}
|
||||
/>
|
||||
|
||||
{name.trim() && (
|
||||
<Button
|
||||
onClick={handleNameSubmit}
|
||||
disabled={isNaming}
|
||||
className={cn(
|
||||
'max-w-[12rem] mx-auto h-10 px-8 text-sm font-light tracking-wide',
|
||||
'bg-white/15 hover:bg-white/22 text-white/80 border-transparent',
|
||||
'rounded-full transition-all duration-300',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
)}
|
||||
variant="ghost"
|
||||
>
|
||||
That's the one.
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fade to white on completion ── */}
|
||||
{fadeOut && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white pointer-events-none"
|
||||
style={{
|
||||
zIndex: 90,
|
||||
animation: 'blobbi-fade-to-white 2s ease-in forwards',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,19 @@
|
||||
/**
|
||||
* BlobbiOnboardingFlow - Main component that orchestrates the onboarding steps
|
||||
*
|
||||
* This component renders the appropriate onboarding step based on the user's
|
||||
* actual profile state. The initial step is derived from whether the profile
|
||||
* exists - not hardcoded.
|
||||
*
|
||||
* MODES:
|
||||
* 1. Full onboarding (default): Auto profile creation → Adoption question → Preview
|
||||
* 2. Adoption only (adoptionOnly=true): Skip directly to Preview for existing profiles
|
||||
*
|
||||
* IMPORTANT: This component should only be rendered when:
|
||||
* - User has no profile (auto-creates profile using kind 0 name)
|
||||
* - User has profile but no pets (shows adoption)
|
||||
* - User wants to adopt another Blobbi (adoptionOnly mode)
|
||||
*
|
||||
* Profile creation is now automatic - no manual name entry step is needed.
|
||||
* BlobbiOnboardingFlow - Immersive hatching ceremony for every new Blobbi
|
||||
*
|
||||
* Every new egg goes through the hatching ceremony - whether it's a user's
|
||||
* first Blobbi or their tenth. The ceremony creates the egg silently in the
|
||||
* background and presents a wordless, emotional hatching experience.
|
||||
*
|
||||
* The `adoptionOnly` prop is accepted for API compatibility but no longer
|
||||
* changes the flow - every egg gets the full ceremony.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useBlobbiOnboarding } from '../hooks/useBlobbiOnboarding';
|
||||
import { BlobbiAdoptionStep } from './BlobbiAdoptionStep';
|
||||
import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
|
||||
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
interface BlobbiOnboardingFlowProps {
|
||||
/** Current profile (null if doesn't exist) */
|
||||
@@ -43,9 +30,11 @@ interface BlobbiOnboardingFlowProps {
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
/** Called when onboarding is complete */
|
||||
onComplete?: () => void;
|
||||
/**
|
||||
* If true, skip profile creation and adoption question, go directly to preview.
|
||||
* Use this for "Adopt another Blobbi" flow for existing users.
|
||||
/** If provided, skip egg creation and use this existing egg for the ceremony. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/**
|
||||
* Accepted for API compatibility. Every new egg goes through the ceremony.
|
||||
* @deprecated No longer changes the flow.
|
||||
*/
|
||||
adoptionOnly?: boolean;
|
||||
}
|
||||
@@ -58,98 +47,20 @@ export function BlobbiOnboardingFlow({
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly = false,
|
||||
existingCompanion,
|
||||
adoptionOnly,
|
||||
}: BlobbiOnboardingFlowProps) {
|
||||
const [showAdoptConfirmDialog, setShowAdoptConfirmDialog] = useState(false);
|
||||
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
coins,
|
||||
} = useBlobbiOnboarding({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('[BlobbiOnboardingFlow] Rendering:', {
|
||||
hasProfile: !!profile,
|
||||
profileName: profile?.name,
|
||||
step: state.step,
|
||||
hasPreview: !!state.preview,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Handle adopt button click - show confirmation dialog
|
||||
const handleAdoptClick = () => {
|
||||
setShowAdoptConfirmDialog(true);
|
||||
};
|
||||
|
||||
// Handle confirm adoption
|
||||
const handleConfirmAdopt = async () => {
|
||||
await actions.adoptPreview();
|
||||
setShowAdoptConfirmDialog(false);
|
||||
};
|
||||
|
||||
// ─── Step: Auto Profile Creation ──────────────────────────────────────────────
|
||||
// Shows a loading state while profile is being auto-created
|
||||
if (state.step === 'creating-profile') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] gap-4 p-8">
|
||||
<Loader2 className="size-10 text-primary animate-spin" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
Setting up your profile...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Adoption Question ──────────────────────────────────────────────────
|
||||
// Shown when profile exists but user has no pets yet
|
||||
if (state.step === 'adoption-question') {
|
||||
return (
|
||||
<BlobbiAdoptionStep
|
||||
blobbonautName={state.blobbonautName || profile?.name}
|
||||
onStartAdoption={actions.startAdoptionPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Egg Preview ────────────────────────────────────────────────────────
|
||||
// Shown when user is previewing/choosing an egg to adopt
|
||||
if (state.step === 'preview' && state.preview) {
|
||||
return (
|
||||
<>
|
||||
<BlobbiEggPreviewCard
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isFirstPreview={state.isFirstPreview}
|
||||
isProcessing={state.isProcessing}
|
||||
actionInProgress={state.actionInProgress === 'reroll' ? 'reroll' : state.actionInProgress === 'adopt' ? 'adopt' : null}
|
||||
onReroll={actions.rerollPreview}
|
||||
onAdopt={handleAdoptClick}
|
||||
onNameChange={actions.setPreviewName}
|
||||
/>
|
||||
|
||||
<BlobbiAdoptionConfirmDialog
|
||||
open={showAdoptConfirmDialog}
|
||||
onOpenChange={setShowAdoptConfirmDialog}
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isAdopting={state.isProcessing && state.actionInProgress === 'adopt'}
|
||||
onConfirm={handleConfirmAdopt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen if parent logic is correct)
|
||||
console.warn('[BlobbiOnboardingFlow] Unexpected state - no matching step');
|
||||
return null;
|
||||
return (
|
||||
<BlobbiHatchingCeremony
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
updateCompanionEvent={updateCompanionEvent}
|
||||
invalidateProfile={invalidateProfile}
|
||||
invalidateCompanion={invalidateCompanion}
|
||||
setStoredSelectedD={setStoredSelectedD}
|
||||
onComplete={onComplete}
|
||||
existingCompanion={existingCompanion}
|
||||
eggOnly={adoptionOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,15 +456,18 @@ export function useBlobbiOnboarding({
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 2. Update profile: deduct coins, add to has, set current_companion
|
||||
// 2. Update profile: deduct coins, add to has list
|
||||
// NOTE: We do NOT set current_companion here because the adopted Blobbi
|
||||
// is still an egg. The companion mechanic only becomes available after hatching.
|
||||
// Eggs should never be auto-assigned as the floating companion.
|
||||
// NOTE: blobbi_onboarding_done is NOT set here — adoption alone does not
|
||||
// complete onboarding. It is set when the first-hatch tour finishes.
|
||||
const newCoins = coins - BLOBBI_ADOPTION_COST;
|
||||
const newHas = [...profile.has, preview.d];
|
||||
|
||||
const profileUpdates: Record<string, string | string[]> = {
|
||||
coins: newCoins.toString(),
|
||||
has: newHas,
|
||||
current_companion: preview.d,
|
||||
onboarding_done: 'true',
|
||||
};
|
||||
|
||||
const updatedProfileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Blobbi Onboarding Module
|
||||
*
|
||||
* Provides components and hooks for the Blobbi onboarding flow:
|
||||
* 1. Auto profile creation (using kind 0 name)
|
||||
* 2. Adoption question
|
||||
* 3. Egg preview with reroll/adopt
|
||||
*
|
||||
* Every new egg goes through the immersive hatching ceremony:
|
||||
* dark screen, huge egg, click-to-hatch, sentimental birth reveal, naming.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { BlobbiAdoptionStep } from './components/BlobbiAdoptionStep';
|
||||
export { BlobbiEggPreviewCard } from './components/BlobbiEggPreviewCard';
|
||||
export { BlobbiAdoptionConfirmDialog } from './components/BlobbiAdoptionConfirmDialog';
|
||||
export { BlobbiOnboardingFlow } from './components/BlobbiOnboardingFlow';
|
||||
export { BlobbiHatchingCeremony } from './components/BlobbiHatchingCeremony';
|
||||
|
||||
// Hooks
|
||||
// Hooks (used internally; kept exported for potential external use)
|
||||
export { useBlobbiOnboarding } from './hooks/useBlobbiOnboarding';
|
||||
export type {
|
||||
OnboardingStep,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EggGraphic, type EggReactionState, type EggStatusEffects } from '@/blobbi/egg';
|
||||
import { EggGraphic, type EggReactionState, type EggStatusEffects, type EggTourVisualState } from '@/blobbi/egg';
|
||||
import { toEggGraphicVisualBlobbi } from '@/blobbi/core/lib/blobbi-egg-adapter';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
@@ -23,7 +23,7 @@ import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
export type BlobbiEggSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { EggReactionState, EggStatusEffects } from '@/blobbi/egg';
|
||||
export type { EggReactionState, EggStatusEffects, EggTourVisualState } from '@/blobbi/egg';
|
||||
|
||||
export interface BlobbiEggVisualProps {
|
||||
/** The Blobbi companion data from parseBlobbiEvent */
|
||||
@@ -36,6 +36,10 @@ export interface BlobbiEggVisualProps {
|
||||
reaction?: EggReactionState;
|
||||
/** Status effects for egg visual feedback (dirty, sick, happy) */
|
||||
statusEffects?: EggStatusEffects;
|
||||
/** Tour visual state - driven externally by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
}
|
||||
@@ -70,6 +74,8 @@ export function BlobbiEggVisual({
|
||||
animated = false,
|
||||
reaction = 'idle',
|
||||
statusEffects,
|
||||
tourVisualState,
|
||||
onTourEggClick,
|
||||
className,
|
||||
}: BlobbiEggVisualProps) {
|
||||
// Memoize adapter output to avoid unnecessary re-renders
|
||||
@@ -103,6 +109,8 @@ export function BlobbiEggVisual({
|
||||
animated={animated && !isSleeping}
|
||||
reaction={effectiveReaction}
|
||||
statusEffects={isSleeping ? undefined : statusEffects}
|
||||
tourVisualState={tourVisualState}
|
||||
onTourEggClick={onTourEggClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+102
-178
@@ -1,50 +1,31 @@
|
||||
/**
|
||||
* BlobbiPhotoModal - Modal for taking and sharing Blobbi photos
|
||||
* BlobbiPhotoModal - Fullscreen photo overlay
|
||||
*
|
||||
* Features:
|
||||
* - Polaroid-style preview of the Blobbi
|
||||
* - Download as PNG
|
||||
* - Post to Nostr with Blossom upload
|
||||
*
|
||||
* Uses html-to-image for DOM-to-PNG conversion.
|
||||
* Simple blurred overlay with the polaroid photo centered,
|
||||
* and download/share buttons below. Tap outside to close.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Download, Send, Loader2, Camera } from 'lucide-react';
|
||||
import { Download, Share2, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BlobbiPolaroidCard } from './BlobbiPolaroidCard';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { trackDailyMissionProgress } from '@/blobbi/actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export interface BlobbiPhotoModalProps {
|
||||
/** Whether the modal is open */
|
||||
open: boolean;
|
||||
/** Callback when the modal should close */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi companion to photograph */
|
||||
companion: BlobbiCompanion;
|
||||
}
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a data URL to a File object
|
||||
*/
|
||||
function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png';
|
||||
@@ -57,218 +38,161 @@ function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
return new File([u8arr], filename, { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download in the browser
|
||||
*/
|
||||
function downloadFile(dataUrl: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPhotoModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
}: BlobbiPhotoModalProps) {
|
||||
const polaroidRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Generate PNG from the polaroid card
|
||||
*/
|
||||
const generateImage = useCallback(async (): Promise<string | null> => {
|
||||
if (!polaroidRef.current) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Could not capture the photo. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!polaroidRef.current) return null;
|
||||
try {
|
||||
// Use html-to-image with high quality settings
|
||||
const dataUrl = await toPng(polaroidRef.current, {
|
||||
return await toPng(polaroidRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // 2x for retina displays
|
||||
pixelRatio: 2,
|
||||
cacheBust: true,
|
||||
// Skip external fonts that might fail to load
|
||||
skipFonts: true,
|
||||
});
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to generate image:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to generate the photo. Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to generate image:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Failed to capture photo.' });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle download action
|
||||
*/
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const dataUrl = await generateImage();
|
||||
if (dataUrl) {
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`;
|
||||
downloadFile(dataUrl, filename);
|
||||
toast({
|
||||
title: 'Photo saved!',
|
||||
description: 'Your Blobbi photo has been downloaded.',
|
||||
});
|
||||
if (!dataUrl) return;
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-photo.png`;
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// On native, use the download utility which handles share sheet
|
||||
const blob = dataUrlToFile(dataUrl, filename);
|
||||
const url = URL.createObjectURL(blob);
|
||||
await openUrl(url);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
toast({ title: 'Photo saved!' });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [generateImage, companion.name]);
|
||||
|
||||
/**
|
||||
* Handle post action - upload to Blossom and create Nostr post
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to post your Blobbi photo.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPosting(true);
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
// Generate the image
|
||||
const dataUrl = await generateImage();
|
||||
if (!dataUrl) {
|
||||
return;
|
||||
}
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Convert to File for upload
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`;
|
||||
const file = dataUrlToFile(dataUrl, filename);
|
||||
|
||||
// Upload to Blossom - returns NIP-94 compatible tags
|
||||
const tags = await uploadFile(file);
|
||||
|
||||
// Extract URL from the 'url' tag (NIP-94 format)
|
||||
// The upload hook returns tags like [['url', '...'], ['m', '...'], ['x', '...'], ...]
|
||||
const urlTag = tags.find((tag) => tag[0] === 'url');
|
||||
if (!urlTag || !urlTag[1]) {
|
||||
throw new Error('Upload succeeded but no URL was returned');
|
||||
}
|
||||
if (!urlTag?.[1]) throw new Error('Upload succeeded but no URL returned');
|
||||
const url = urlTag[1];
|
||||
|
||||
// Build imeta tag from all NIP-94 tags
|
||||
// Format: ['imeta', 'url https://...', 'm image/png', 'x abc123', ...]
|
||||
const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`);
|
||||
|
||||
// Create the post content
|
||||
const content = `${companion.name} ${url}`;
|
||||
|
||||
// Publish kind 1 event
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
content: `${companion.name} ${url}`,
|
||||
tags: [['imeta', ...imetaFields]],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Posted!',
|
||||
description: 'Your Blobbi photo has been shared.',
|
||||
});
|
||||
|
||||
// Track daily mission progress for photo action
|
||||
toast({ title: 'Posted!', description: 'Your Blobbi photo has been shared.' });
|
||||
trackDailyMissionProgress('take_photo', 1, user.pubkey);
|
||||
|
||||
// Close the modal after successful post
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to post:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to share:', error);
|
||||
toast({ variant: 'destructive', title: 'Failed to post', description: error instanceof Error ? error.message : 'Please try again.' });
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]);
|
||||
|
||||
const isProcessing = isGenerating || isPosting;
|
||||
if (!open) return null;
|
||||
|
||||
const isProcessing = isDownloading || isSharing;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Camera className="size-5" />
|
||||
Take a Photo
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Capture a polaroid-style photo of {companion.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||
{/* Backdrop — tap to close */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/60 backdrop-blur-sm"
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Polaroid preview - centered */}
|
||||
<div className="flex justify-center py-4">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
{/* Close button — top-right of the container */}
|
||||
<button
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
className="absolute top-3 right-3 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
{/* Polaroid card */}
|
||||
<div className="relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative z-10 flex items-center gap-6 mt-8">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-sky-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #0ea5e9 25%, transparent), color-mix(in srgb, #0ea5e9 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isDownloading ? <Loader2 className="size-6 animate-spin" /> : <Download className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Save</span>
|
||||
</button>
|
||||
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4 mr-2" />
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={isProcessing || !user}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPosting ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4 mr-2" />
|
||||
)}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login hint if not logged in */}
|
||||
{!user && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Log in to post your Blobbi photo
|
||||
</p>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-violet-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #8b5cf6 25%, transparent), color-mix(in srgb, #8b5cf6 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isSharing ? <Loader2 className="size-6 animate-spin" /> : <Share2 className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Post</span>
|
||||
</button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects } from './BlobbiEggVisual';
|
||||
import { BlobbiEggVisual, type BlobbiEggSize, type EggStatusEffects, type EggTourVisualState } from './BlobbiEggVisual';
|
||||
import { BlobbiBabyVisual } from './BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from './BlobbiAdultVisual';
|
||||
import { FloatingMusicNotes } from './FloatingMusicNotes';
|
||||
@@ -50,6 +50,10 @@ export interface BlobbiStageVisualProps {
|
||||
* Status-reaction body effects are already in the recipe.
|
||||
*/
|
||||
bodyEffects?: BodyEffectsSpec;
|
||||
/** Tour visual state for egg stage - driven by the tour orchestration layer */
|
||||
tourVisualState?: EggTourVisualState;
|
||||
/** Callback when the egg is clicked during an interactive tour step */
|
||||
onTourEggClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -74,6 +78,8 @@ export function BlobbiStageVisual({
|
||||
recipeLabel,
|
||||
emotion = 'neutral',
|
||||
bodyEffects,
|
||||
tourVisualState,
|
||||
onTourEggClick,
|
||||
className,
|
||||
}: BlobbiStageVisualProps) {
|
||||
const { stage } = companion;
|
||||
@@ -109,6 +115,8 @@ export function BlobbiStageVisual({
|
||||
animated={animated}
|
||||
reaction={effectiveReaction}
|
||||
statusEffects={eggStatusEffects}
|
||||
tourVisualState={tourVisualState}
|
||||
onTourEggClick={onTourEggClick}
|
||||
className="size-full"
|
||||
/>
|
||||
<FloatingMusicNotes active={showMusicNotes} />
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* ActionBarEditor - Lightweight modal for customizing the bottom action bar.
|
||||
*
|
||||
* Rules:
|
||||
* - Main Action + More are fixed (always shown, not editable)
|
||||
* - Up to 3 custom visible slots
|
||||
* - User can toggle visibility, reorder, and highlight one item
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Star,
|
||||
Egg,
|
||||
Target,
|
||||
Package,
|
||||
Camera,
|
||||
Footprints,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
type ActionBarPreferences,
|
||||
type BarItemId,
|
||||
BAR_ITEM_LABELS,
|
||||
MAX_VISIBLE_SLOTS,
|
||||
toggleSlotVisibility,
|
||||
toggleSlotHighlight,
|
||||
moveSlotUp,
|
||||
moveSlotDown,
|
||||
visibleCount,
|
||||
DEFAULT_PREFERENCES,
|
||||
} from '../lib/action-bar-preferences';
|
||||
|
||||
// ─── Icon Mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BAR_ITEM_ICONS: Record<BarItemId, React.ReactNode> = {
|
||||
blobbies: <Egg className="size-4" />,
|
||||
missions: <Target className="size-4" />,
|
||||
items: <Package className="size-4" />,
|
||||
take_photo: <Camera className="size-4" />,
|
||||
set_companion: <Footprints className="size-4" />,
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ActionBarEditorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
preferences: ActionBarPreferences;
|
||||
onUpdate: (prefs: ActionBarPreferences) => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ActionBarEditor({
|
||||
open,
|
||||
onOpenChange,
|
||||
preferences,
|
||||
onUpdate,
|
||||
}: ActionBarEditorProps) {
|
||||
const currentVisible = visibleCount(preferences);
|
||||
const atMax = currentVisible >= MAX_VISIBLE_SLOTS;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: BarItemId) => onUpdate(toggleSlotVisibility(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleHighlight = useCallback(
|
||||
(id: BarItemId) => onUpdate(toggleSlotHighlight(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleUp = useCallback(
|
||||
(id: BarItemId) => onUpdate(moveSlotUp(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleDown = useCallback(
|
||||
(id: BarItemId) => onUpdate(moveSlotDown(preferences, id)),
|
||||
[preferences, onUpdate],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => onUpdate(DEFAULT_PREFERENCES),
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">Edit Action Bar</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Choose up to {MAX_VISIBLE_SLOTS} items. Main Action and More are always shown.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1 py-2">
|
||||
{preferences.slots.map((slot, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === preferences.slots.length - 1;
|
||||
const canTurnOn = slot.visible || !atMax;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 transition-colors',
|
||||
slot.visible
|
||||
? 'bg-accent/60'
|
||||
: 'bg-muted/30 opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Icon + Label */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{BAR_ITEM_ICONS[slot.id]}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{BAR_ITEM_LABELS[slot.id]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Highlight toggle */}
|
||||
{slot.visible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', slot.highlighted && 'text-amber-500')}
|
||||
onClick={() => handleHighlight(slot.id)}
|
||||
title={slot.highlighted ? 'Remove highlight' : 'Highlight'}
|
||||
>
|
||||
<Star className={cn('size-3.5', slot.highlighted && 'fill-current')} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Reorder controls */}
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
disabled={isFirst}
|
||||
onClick={() => handleUp(slot.id)}
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
disabled={isLast}
|
||||
onClick={() => handleDown(slot.id)}
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
disabled={!canTurnOn && !slot.visible}
|
||||
onClick={() => handleToggle(slot.id)}
|
||||
title={slot.visible ? 'Hide' : 'Show'}
|
||||
>
|
||||
{slot.visible ? (
|
||||
<Eye className="size-3.5" />
|
||||
) : (
|
||||
<EyeOff className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Slot counter + reset */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentVisible}/{MAX_VISIBLE_SLOTS} slots used
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* MissionSurfaceCard - Compact inline card that surfaces ONE relevant
|
||||
* mission/task at a time below the Blobbi visual.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Hatch / Evolve tasks (lifecycle progression)
|
||||
* 2. Daily missions (engagement / coin loop)
|
||||
*
|
||||
* Carousel:
|
||||
* - Auto-rotates every ~5s when > 1 card available
|
||||
* - Manual tap cycles to the next card
|
||||
* - Auto-advances when the current card's mission completes
|
||||
* - Single card = no rotation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Target,
|
||||
ChevronRight,
|
||||
Egg,
|
||||
Sparkles,
|
||||
Coins,
|
||||
CircleDot,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
|
||||
|
||||
// ─── Card Item Types ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TaskCardItem {
|
||||
kind: 'task';
|
||||
badge: 'Hatch' | 'Evolve';
|
||||
title: string;
|
||||
description: string;
|
||||
progress: number; // 0-100
|
||||
progressLabel: string;
|
||||
}
|
||||
|
||||
interface DailyCardItem {
|
||||
kind: 'daily';
|
||||
badge: 'Daily';
|
||||
title: string;
|
||||
description: string;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
reward: number;
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
type CardItem = TaskCardItem | DailyCardItem;
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MissionSurfaceCardProps {
|
||||
/** Hatch or evolve tasks (from useActiveTaskProcess) */
|
||||
tasks: HatchTask[];
|
||||
/** Whether a task process (incubating/evolving) is active */
|
||||
isInTaskProcess: boolean;
|
||||
/** Process type for badge label */
|
||||
processType: 'hatch' | 'evolve' | null;
|
||||
/** Daily missions */
|
||||
dailyMissions: DailyMission[];
|
||||
/** Called when user taps "View all" */
|
||||
onViewAll: () => void;
|
||||
/** Called when user dismisses the card */
|
||||
onHide?: () => void;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildTaskCards(
|
||||
tasks: HatchTask[],
|
||||
processType: 'hatch' | 'evolve' | null,
|
||||
): TaskCardItem[] {
|
||||
if (!processType) return [];
|
||||
const badge = processType === 'hatch' ? 'Hatch' : 'Evolve';
|
||||
|
||||
// Show only incomplete tasks, or the first completed one if all are done
|
||||
const incomplete = tasks.filter((t) => !t.completed);
|
||||
const toShow = incomplete.length > 0 ? incomplete : tasks.slice(0, 1);
|
||||
|
||||
return toShow.map((t) => ({
|
||||
kind: 'task',
|
||||
badge: badge as 'Hatch' | 'Evolve',
|
||||
title: t.name,
|
||||
description: t.description,
|
||||
progress: t.required > 0 ? Math.min(100, Math.round((t.current / t.required) * 100)) : 0,
|
||||
progressLabel: `${t.current}/${t.required}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDailyCards(missions: DailyMission[]): DailyCardItem[] {
|
||||
// Show unclaimed missions first, then claimed ones
|
||||
const unclaimed = missions.filter((m) => !m.claimed);
|
||||
const toShow = unclaimed.length > 0 ? unclaimed : [];
|
||||
|
||||
return toShow.map((m) => ({
|
||||
kind: 'daily',
|
||||
badge: 'Daily',
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
progress: m.requiredCount > 0
|
||||
? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100))
|
||||
: 0,
|
||||
progressLabel: `${m.currentCount}/${m.requiredCount}`,
|
||||
reward: m.reward,
|
||||
claimed: m.claimed,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Auto-rotate interval ─────────────────────────────────────────────────────
|
||||
const ROTATE_INTERVAL_MS = 5000;
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function MissionSurfaceCard({
|
||||
tasks,
|
||||
isInTaskProcess,
|
||||
processType,
|
||||
dailyMissions,
|
||||
onViewAll,
|
||||
onHide,
|
||||
className,
|
||||
}: MissionSurfaceCardProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Build card list: tasks first (priority), then daily
|
||||
const cards = useMemo<CardItem[]>(() => {
|
||||
const taskCards = isInTaskProcess ? buildTaskCards(tasks, processType) : [];
|
||||
const dailyCards = buildDailyCards(dailyMissions);
|
||||
return [...taskCards, ...dailyCards];
|
||||
}, [tasks, isInTaskProcess, processType, dailyMissions]);
|
||||
|
||||
// Clamp index if cards shrink
|
||||
useEffect(() => {
|
||||
if (activeIndex >= cards.length && cards.length > 0) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [cards.length, activeIndex]);
|
||||
|
||||
// Auto-rotate (only when > 1 card)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cards.length <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % cards.length);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [cards.length]);
|
||||
|
||||
// Manual cycle
|
||||
const handleCycle = useCallback(() => {
|
||||
if (cards.length <= 1) return;
|
||||
// Reset auto-rotate timer
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % cards.length);
|
||||
setIsAnimating(false);
|
||||
// Restart timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setActiveIndex((prev) => (prev + 1) % cards.length);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
}, 150);
|
||||
}, [cards.length]);
|
||||
|
||||
// Nothing to show
|
||||
if (cards.length === 0) return null;
|
||||
|
||||
const card = cards[Math.min(activeIndex, cards.length - 1)];
|
||||
|
||||
const badgeColor =
|
||||
card.badge === 'Hatch'
|
||||
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
|
||||
: card.badge === 'Evolve'
|
||||
? 'bg-purple-500/15 text-purple-600 dark:text-purple-400'
|
||||
: 'bg-primary/10 text-primary';
|
||||
|
||||
const badgeIcon =
|
||||
card.badge === 'Hatch' ? (
|
||||
<Egg className="size-3" />
|
||||
) : card.badge === 'Evolve' ? (
|
||||
<Sparkles className="size-3" />
|
||||
) : (
|
||||
<Target className="size-3" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<button
|
||||
onClick={handleCycle}
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border border-border/60 bg-card/80 backdrop-blur-sm',
|
||||
'px-3.5 py-2.5 transition-all duration-200',
|
||||
'hover:bg-accent/40 active:scale-[0.99]',
|
||||
isAnimating && 'opacity-0 translate-x-2',
|
||||
!isAnimating && 'opacity-100 translate-x-0',
|
||||
)}
|
||||
>
|
||||
{/* Top row: badge + title + view all */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-[10px] font-medium px-1.5 py-0 h-4 gap-1', badgeColor)}
|
||||
>
|
||||
{badgeIcon}
|
||||
{card.badge}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{card.title}
|
||||
</span>
|
||||
{/* Dot indicators when multiple cards */}
|
||||
{cards.length > 1 && (
|
||||
<div className="flex gap-0.5 items-center shrink-0">
|
||||
{cards.map((_, i) => (
|
||||
<CircleDot
|
||||
key={i}
|
||||
className={cn(
|
||||
'size-2 transition-colors',
|
||||
i === activeIndex
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground/30',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Dismiss button */}
|
||||
{onHide && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onHide();
|
||||
}}
|
||||
className="shrink-0 p-0.5 -m-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||
title="Hide mission card"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground mb-2 line-clamp-1">
|
||||
{card.description}
|
||||
</p>
|
||||
|
||||
{/* Bottom row: progress bar + label + reward/view all */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={card.progress}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
|
||||
{card.progressLabel}
|
||||
</span>
|
||||
{card.kind === 'daily' && !card.claimed && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium shrink-0">
|
||||
<Coins className="size-2.5" />
|
||||
{card.reward}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* View all link */}
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="flex items-center gap-1 mx-auto mt-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View all missions
|
||||
<ChevronRight className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Action Bar Preferences
|
||||
*
|
||||
* Lightweight localStorage-backed model controlling which items are
|
||||
* visible in the BlobbiBottomBar and in which order.
|
||||
*
|
||||
* Fixed items (cannot be hidden or reordered by the user):
|
||||
* - Main Action (center button) -- always present
|
||||
* - More (right-most button) -- always present
|
||||
*
|
||||
* Customizable items (up to 3 visible slots):
|
||||
* Candidates: Blobbies, Missions, Items, Take Photo, Set as Companion
|
||||
*
|
||||
* Persistence: localStorage only for now. Shape is designed so it can
|
||||
* later migrate to a Nostr event tag.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Identifiers for customizable bottom-bar items */
|
||||
export type BarItemId =
|
||||
| 'blobbies'
|
||||
| 'missions'
|
||||
| 'items'
|
||||
| 'take_photo'
|
||||
| 'set_companion';
|
||||
|
||||
/** A single customizable bar slot */
|
||||
export interface BarItemSlot {
|
||||
id: BarItemId;
|
||||
visible: boolean;
|
||||
/** If true, this item receives a subtle highlight ring in the bar */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
/** Full persisted preference shape */
|
||||
export interface ActionBarPreferences {
|
||||
/** Ordered list of customizable items. Visible items render in array order. */
|
||||
slots: BarItemSlot[];
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Max visible customizable items (Main Action + More are fixed) */
|
||||
export const MAX_VISIBLE_SLOTS = 3;
|
||||
|
||||
/** localStorage key for bar slot preferences */
|
||||
export const STORAGE_KEY = 'blobbi:action-bar-prefs';
|
||||
|
||||
/** localStorage key for inline mission surface card visibility */
|
||||
export const MISSION_CARD_STORAGE_KEY = 'blobbi:mission-card-visible';
|
||||
|
||||
/** Human-readable labels */
|
||||
export const BAR_ITEM_LABELS: Record<BarItemId, string> = {
|
||||
blobbies: 'Blobbies',
|
||||
missions: 'Missions',
|
||||
items: 'Items',
|
||||
take_photo: 'Take Photo',
|
||||
set_companion: 'Companion',
|
||||
};
|
||||
|
||||
/** Default preferences: only Blobbies visible, others hidden */
|
||||
export const DEFAULT_PREFERENCES: ActionBarPreferences = {
|
||||
slots: [
|
||||
{ id: 'blobbies', visible: true },
|
||||
{ id: 'missions', visible: false },
|
||||
{ id: 'items', visible: false },
|
||||
{ id: 'take_photo', visible: false },
|
||||
{ id: 'set_companion', visible: false },
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return only visible slots, in order */
|
||||
export function getVisibleSlots(prefs: ActionBarPreferences): BarItemSlot[] {
|
||||
return prefs.slots.filter((s) => s.visible);
|
||||
}
|
||||
|
||||
/** Count of currently visible custom items */
|
||||
export function visibleCount(prefs: ActionBarPreferences): number {
|
||||
return prefs.slots.filter((s) => s.visible).length;
|
||||
}
|
||||
|
||||
/** Can we show one more item? */
|
||||
export function canShowMore(prefs: ActionBarPreferences): boolean {
|
||||
return visibleCount(prefs) < MAX_VISIBLE_SLOTS;
|
||||
}
|
||||
|
||||
/** Toggle visibility of a slot. Enforces MAX_VISIBLE_SLOTS. */
|
||||
export function toggleSlotVisibility(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
const slot = prefs.slots.find((s) => s.id === id);
|
||||
if (!slot) return prefs;
|
||||
|
||||
// If turning ON and already at max, reject
|
||||
if (!slot.visible && !canShowMore(prefs)) return prefs;
|
||||
|
||||
return {
|
||||
slots: prefs.slots.map((s) =>
|
||||
s.id === id ? { ...s, visible: !s.visible } : s,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Toggle highlight on a slot (only one can be highlighted at a time) */
|
||||
export function toggleSlotHighlight(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
return {
|
||||
slots: prefs.slots.map((s) =>
|
||||
s.id === id
|
||||
? { ...s, highlighted: !s.highlighted }
|
||||
: { ...s, highlighted: false },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Move a slot up (earlier) in the list */
|
||||
export function moveSlotUp(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
const idx = prefs.slots.findIndex((s) => s.id === id);
|
||||
if (idx <= 0) return prefs;
|
||||
const newSlots = [...prefs.slots];
|
||||
[newSlots[idx - 1], newSlots[idx]] = [newSlots[idx], newSlots[idx - 1]];
|
||||
return { slots: newSlots };
|
||||
}
|
||||
|
||||
/** Move a slot down (later) in the list */
|
||||
export function moveSlotDown(
|
||||
prefs: ActionBarPreferences,
|
||||
id: BarItemId,
|
||||
): ActionBarPreferences {
|
||||
const idx = prefs.slots.findIndex((s) => s.id === id);
|
||||
if (idx < 0 || idx >= prefs.slots.length - 1) return prefs;
|
||||
const newSlots = [...prefs.slots];
|
||||
[newSlots[idx], newSlots[idx + 1]] = [newSlots[idx + 1], newSlots[idx]];
|
||||
return { slots: newSlots };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and repair preferences loaded from localStorage.
|
||||
* Adds missing candidates, removes unknown ids, preserves order.
|
||||
*/
|
||||
export function validatePreferences(raw: unknown): ActionBarPreferences {
|
||||
if (!raw || typeof raw !== 'object' || !('slots' in raw)) {
|
||||
return DEFAULT_PREFERENCES;
|
||||
}
|
||||
|
||||
const obj = raw as { slots: unknown };
|
||||
if (!Array.isArray(obj.slots)) return DEFAULT_PREFERENCES;
|
||||
|
||||
const knownIds = new Set<BarItemId>(DEFAULT_PREFERENCES.slots.map((s) => s.id));
|
||||
const seenIds = new Set<BarItemId>();
|
||||
|
||||
// Keep valid existing entries
|
||||
const cleaned: BarItemSlot[] = [];
|
||||
for (const item of obj.slots) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
'id' in item &&
|
||||
typeof (item as BarItemSlot).id === 'string' &&
|
||||
knownIds.has((item as BarItemSlot).id) &&
|
||||
!seenIds.has((item as BarItemSlot).id)
|
||||
) {
|
||||
const slot = item as BarItemSlot;
|
||||
seenIds.add(slot.id);
|
||||
cleaned.push({
|
||||
id: slot.id,
|
||||
visible: typeof slot.visible === 'boolean' ? slot.visible : false,
|
||||
highlighted: typeof slot.highlighted === 'boolean' ? slot.highlighted : false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add any missing candidates (new features added after user saved prefs)
|
||||
for (const def of DEFAULT_PREFERENCES.slots) {
|
||||
if (!seenIds.has(def.id)) {
|
||||
cleaned.push({ ...def });
|
||||
}
|
||||
}
|
||||
|
||||
return { slots: cleaned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preferences from localStorage with validation.
|
||||
*/
|
||||
export function loadPreferences(): ActionBarPreferences {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_PREFERENCES;
|
||||
return validatePreferences(JSON.parse(raw));
|
||||
} catch {
|
||||
return DEFAULT_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save preferences to localStorage.
|
||||
*/
|
||||
export function savePreferences(prefs: ActionBarPreferences): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// Silently fail (quota, SSR, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mission Surface Card Visibility ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load the inline mission card visibility preference.
|
||||
* Defaults to `true` (visible).
|
||||
*/
|
||||
export function loadMissionCardVisible(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(MISSION_CARD_STORAGE_KEY);
|
||||
if (raw === null) return true;
|
||||
return raw === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the inline mission card visibility preference.
|
||||
*/
|
||||
export function saveMissionCardVisible(visible: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(MISSION_CARD_STORAGE_KEY, String(visible));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Visible-in-bar Set Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the set of BarItemIds currently visible in the bottom bar.
|
||||
* Used by the More dropdown to skip items that are already in the bar.
|
||||
*/
|
||||
export function getVisibleBarIds(prefs: ActionBarPreferences): Set<BarItemId> {
|
||||
return new Set(prefs.slots.filter((s) => s.visible).map((s) => s.id));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi {
|
||||
size: companion.visualTraits.size,
|
||||
// Metadata
|
||||
seed: companion.seed,
|
||||
tags: companion.allTags,
|
||||
tags: companion.allTags ?? [],
|
||||
// Adult-specific data (for adult form resolution)
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
|
||||
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
|
||||
Users, Zap,
|
||||
Users, Vote, Zap,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAddrEvent, useEvent } from '@/hooks/useEvent';
|
||||
import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBookInfo } from '@/hooks/useBookInfo';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
@@ -44,26 +45,38 @@ interface CommentRoot {
|
||||
identifier?: string;
|
||||
/** Root kind number (from K tag). */
|
||||
rootKind?: string;
|
||||
/** Relay URL hint from the E or A tag (position [2]). */
|
||||
relayHint?: string;
|
||||
/** Author pubkey hint extracted from the E tag (position [3]) or P tag. */
|
||||
authorHint?: string;
|
||||
}
|
||||
|
||||
/** Parse the root reference from a kind 1111 comment's tags. */
|
||||
function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
|
||||
const aTag = event.tags.find(([name]) => name === 'A')?.[1];
|
||||
const aTagFull = event.tags.find(([name]) => name === 'A');
|
||||
// Use find (not findLast) to get the root E tag, not a parent e tag
|
||||
const eTag = event.tags.find(([name]) => name === 'E')?.[1];
|
||||
const eTagFull = event.tags.find(([name]) => name === 'E');
|
||||
const iTag = event.tags.find(([name]) => name === 'I')?.[1];
|
||||
const kTag = event.tags.find(([name]) => name === 'K')?.[1];
|
||||
// P tag holds the root event author's pubkey — used as author hint fallback
|
||||
const pTag = event.tags.find(([name]) => name === 'P')?.[1];
|
||||
|
||||
if (aTag) {
|
||||
if (aTagFull) {
|
||||
const aTag = aTagFull[1];
|
||||
const relayHint = aTagFull[2] || undefined;
|
||||
const parts = aTag.split(':');
|
||||
const kind = parseInt(parts[0], 10);
|
||||
const pubkey = parts[1] ?? '';
|
||||
const identifier = parts.slice(2).join(':');
|
||||
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag };
|
||||
return { type: 'addr', addr: { kind, pubkey, identifier }, rootKind: kTag, relayHint };
|
||||
}
|
||||
|
||||
if (eTag) {
|
||||
return { type: 'event', eventId: eTag, rootKind: kTag };
|
||||
if (eTagFull) {
|
||||
const eTag = eTagFull[1];
|
||||
const relayHint = eTagFull[2] || undefined;
|
||||
// NIP-22 E tags may have the author pubkey at position [3]; fall back to P tag
|
||||
const authorHint = eTagFull[3] || pTag || undefined;
|
||||
return { type: 'event', eventId: eTag, rootKind: kTag, relayHint, authorHint };
|
||||
}
|
||||
|
||||
if (iTag) {
|
||||
@@ -91,6 +104,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
22: 'a short video',
|
||||
62: 'a request to vanish',
|
||||
1063: 'a file',
|
||||
1018: 'a vote',
|
||||
1068: 'a poll',
|
||||
1111: 'a comment',
|
||||
1222: 'a voice message',
|
||||
@@ -108,14 +122,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',
|
||||
@@ -140,6 +156,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
21: Film,
|
||||
22: Film,
|
||||
1063: FileText,
|
||||
1018: Vote,
|
||||
1068: BarChart3,
|
||||
1222: Mic,
|
||||
1617: FileText,
|
||||
@@ -154,9 +171,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 +229,11 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
34139: 'playlist',
|
||||
};
|
||||
|
||||
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */
|
||||
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto Zapstore app"). */
|
||||
const KIND_POSTFIXES: Partial<Record<number, string>> = {
|
||||
32267: 'on Zapstore',
|
||||
30063: 'release',
|
||||
30063: 'Zapstore release',
|
||||
3063: 'Zapstore asset',
|
||||
};
|
||||
|
||||
/** Get a display name for an event based on its kind and tags. */
|
||||
@@ -228,6 +248,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];
|
||||
@@ -473,7 +503,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
|
||||
|
||||
/** Comment context for non-profile addressable event roots (A tag). */
|
||||
function GenericAddrCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
const { data: event, isLoading } = useAddrEvent(root.addr);
|
||||
const { data: event, isLoading } = useAddrEvent(root.addr, root.relayHint ? [root.relayHint] : undefined);
|
||||
|
||||
const isCommunity = root.rootKind === '34550' || root.addr?.kind === 34550;
|
||||
const prefix = isCommunity ? 'Posted in' : 'Commenting on';
|
||||
@@ -511,18 +541,33 @@ function GenericAddrCommentContext({ root, className }: { root: CommentRoot; cla
|
||||
|
||||
/** Comment context for regular event roots (E tag). */
|
||||
function EventCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
const { data: event, isLoading } = useEvent(root.eventId);
|
||||
const { data: event, isLoading } = useEvent(
|
||||
root.eventId,
|
||||
root.relayHint ? [root.relayHint] : undefined,
|
||||
root.authorHint,
|
||||
);
|
||||
|
||||
// Kind 7 reactions get special treatment
|
||||
if (event?.kind === 7) {
|
||||
return <ReactionCommentContext event={event} className={className} />;
|
||||
}
|
||||
|
||||
// Kind 1018 poll votes get special treatment
|
||||
if (event?.kind === 1018) {
|
||||
return <PollVoteCommentContext event={event} className={className} />;
|
||||
}
|
||||
|
||||
const display = event ? getEventDisplayName(event) : { text: getRootKindLabel(root.rootKind) };
|
||||
const link = event ? getRootLink(event) : undefined;
|
||||
|
||||
const hoverContent = root.eventId ? (
|
||||
<EmbeddedNote eventId={root.eventId} className="border-0 rounded-none" disableHoverCards />
|
||||
<EmbeddedNote
|
||||
eventId={root.eventId}
|
||||
relays={root.relayHint ? [root.relayHint] : undefined}
|
||||
authorHint={root.authorHint}
|
||||
className="border-0 rounded-none"
|
||||
disableHoverCards
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
@@ -571,6 +616,43 @@ function ReactionCommentContext({ event, className }: { event: NostrEvent; class
|
||||
);
|
||||
}
|
||||
|
||||
/** Comment context for kind 1018 poll vote roots — shows "Commenting on @{name}'s vote for {option}". */
|
||||
function PollVoteCommentContext({ event, className }: { event: NostrEvent; className?: string }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const voteLink = getRootLink(event);
|
||||
const profileLink = `/${nip19.npubEncode(event.pubkey)}`;
|
||||
|
||||
const voteLabel = usePollVoteLabel(event);
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className}>
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3.5 w-16 inline-block" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileLink}
|
||||
className="text-primary hover:underline truncate cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<Link
|
||||
to={voteLink}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Vote className="size-3.5 shrink-0" />
|
||||
{voteLabel ? `vote for ${voteLabel}` : 'vote'}
|
||||
</Link>
|
||||
</CommentContextRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Comment context for external content roots (I tag). */
|
||||
function ExternalCommentContext({ root, className }: { root: CommentRoot; className?: string }) {
|
||||
const identifier = root.identifier ?? '';
|
||||
|
||||
+117
-101
@@ -20,6 +20,7 @@ import { GifPicker } from '@/components/GifPicker';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
@@ -201,7 +202,6 @@ export function ComposeBox({
|
||||
|
||||
// Poll mode state
|
||||
const [mode, setMode] = useState<'post' | 'poll'>(initialMode);
|
||||
const [pollQuestion, setPollQuestion] = useState('');
|
||||
const [pollOptions, setPollOptions] = useState([
|
||||
{ id: pollOptionId(), label: '' },
|
||||
{ id: pollOptionId(), label: '' },
|
||||
@@ -224,6 +224,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 +947,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 +981,8 @@ export function ComposeBox({
|
||||
|
||||
const handlePollSubmit = async () => {
|
||||
const filledOptions = pollOptions.filter((o) => o.label.trim());
|
||||
if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return;
|
||||
const finalContent = content.trim();
|
||||
if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return;
|
||||
|
||||
const tags: string[][] = [];
|
||||
for (const opt of filledOptions) {
|
||||
@@ -980,16 +992,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 +1023,7 @@ export function ComposeBox({
|
||||
};
|
||||
|
||||
const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length;
|
||||
const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2;
|
||||
const isPollValid = content.trim().length > 0 && pollFilledCount >= 2;
|
||||
|
||||
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
|
||||
|
||||
@@ -1007,7 +1031,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 +1079,83 @@ export function ComposeBox({
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{mode === 'poll' ? (
|
||||
/* ── Inline poll builder ─────────────────────────────── */
|
||||
<div className="pt-2.5 pb-1 space-y-3">
|
||||
{!previewMode ? (
|
||||
/* ── Edit mode — Textarea ────────────────────────────── */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={expand}
|
||||
onPaste={handlePaste}
|
||||
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
|
||||
className={cn(
|
||||
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
|
||||
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
|
||||
)}
|
||||
rows={1}
|
||||
disabled={!user}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MentionAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertMention={handleInsertMention}
|
||||
/>
|
||||
<EmojiShortcodeAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertEmoji={handleInsertShortcodeEmoji}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Preview mode - Show how post will look */
|
||||
mockEvent && (() => {
|
||||
const imetaMap = parseImetaMap(mockEvent.tags);
|
||||
const videos = extractVideoUrls(mockEvent.content);
|
||||
const imetaAudios = Array.from(imetaMap.values())
|
||||
.filter((e) => e.mime?.startsWith('audio/'))
|
||||
.map((e) => e.url);
|
||||
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
|
||||
const webxdcApps = Array.from(imetaMap.values()).filter(
|
||||
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
|
||||
);
|
||||
return (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={mockEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Poll options + settings — shown below the normal textarea/preview */}
|
||||
{mode === 'poll' && (
|
||||
<div className="space-y-3 pt-1">
|
||||
{/* Back to post link — hidden when poll mode is the only mode */}
|
||||
{initialMode !== 'poll' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('post')}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back to post
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Question */}
|
||||
<textarea
|
||||
value={pollQuestion}
|
||||
onChange={(e) => setPollQuestion(e.target.value)}
|
||||
placeholder="Ask a question…"
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
|
||||
/>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-1.5">
|
||||
{pollOptions.map((opt, idx) => (
|
||||
@@ -1160,66 +1236,6 @@ export function ComposeBox({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : !previewMode ? (
|
||||
/* ── Edit mode — Textarea ────────────────────────────── */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={expand}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
|
||||
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
|
||||
)}
|
||||
rows={1}
|
||||
disabled={!user}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MentionAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertMention={handleInsertMention}
|
||||
/>
|
||||
<EmojiShortcodeAutocomplete
|
||||
textareaRef={textareaRef}
|
||||
content={content}
|
||||
onInsertEmoji={handleInsertShortcodeEmoji}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Preview mode - Show how post will look */
|
||||
mockEvent && (() => {
|
||||
const imetaMap = parseImetaMap(mockEvent.tags);
|
||||
const videos = extractVideoUrls(mockEvent.content);
|
||||
const imetaAudios = Array.from(imetaMap.values())
|
||||
.filter((e) => e.mime?.startsWith('audio/'))
|
||||
.map((e) => e.url);
|
||||
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
|
||||
const webxdcApps = Array.from(imetaMap.values()).filter(
|
||||
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
|
||||
);
|
||||
return (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={mockEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Content warning input */}
|
||||
@@ -1251,7 +1267,7 @@ export function ComposeBox({
|
||||
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
|
||||
}} />
|
||||
) : (
|
||||
<EmbeddedNote eventId={quotedEvent.id} />
|
||||
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1472,11 +1488,11 @@ export function ComposeBox({
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
|
||||
import { isCustomEmoji, getCustomEmojiUrl, buildEmojiMap, type ResolvedEmoji } from '@/lib/customEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Threshold at or below which we apply nearest-neighbor scaling. */
|
||||
const PIXEL_ART_MAX = 16;
|
||||
|
||||
interface CustomEmojiImgProps {
|
||||
/** The shortcode name (without colons). */
|
||||
name: string;
|
||||
@@ -14,16 +17,30 @@ interface CustomEmojiImgProps {
|
||||
|
||||
/**
|
||||
* Renders a single custom emoji as an inline image.
|
||||
*
|
||||
* If the image's natural dimensions are 16x16 or smaller, nearest-neighbor
|
||||
* (`image-rendering: pixelated`) scaling is applied to preserve crisp pixels.
|
||||
*/
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] align-text-bottom' }: CustomEmojiImgProps) {
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
|
||||
const [pixelated, setPixelated] = useState(false);
|
||||
|
||||
const handleLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalWidth <= PIXEL_ART_MAX && img.naturalHeight <= PIXEL_ART_MAX) {
|
||||
setPixelated(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={`:${name}:`}
|
||||
title={`:${name}:`}
|
||||
className={className}
|
||||
style={pixelated ? { imageRendering: 'pixelated' } : undefined}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +69,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 +87,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>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
|
||||
@@ -172,12 +173,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
className="group relative"
|
||||
title={`:${emoji.shortcode}:`}
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={`:${emoji.shortcode}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain rounded transition-transform group-hover:scale-125"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { SortableList, SortableItem } from '@/components/SortableList';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
@@ -506,11 +507,10 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
>
|
||||
<div className="flex items-center gap-2 pr-2 py-1.5">
|
||||
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
|
||||
@@ -375,11 +376,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{emoji.customUrl ? (
|
||||
<img
|
||||
src={emoji.customUrl}
|
||||
alt={`:${emoji.name}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.name}
|
||||
url={emoji.customUrl}
|
||||
className="size-5 object-contain shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import QRCode from 'qrcode';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
|
||||
|
||||
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
|
||||
const MIN_QR_CONTRAST = 3;
|
||||
|
||||
/** Saturation threshold (%) above which a color is considered "colorful". */
|
||||
const COLORFUL_SAT_MIN = 15;
|
||||
/** Lightness range within which a color appears visually colorful. */
|
||||
const COLORFUL_L_MIN = 20;
|
||||
const COLORFUL_L_MAX = 80;
|
||||
|
||||
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
|
||||
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
|
||||
if (!raw) return null;
|
||||
const { h, s, l } = parseHsl(raw);
|
||||
if ([h, s, l].some(isNaN)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function darkenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l > 0 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.max(0, l - 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function lightenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l < 100 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.min(100, l + 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best module color from primary and foreground.
|
||||
*
|
||||
* Strongly prefers primary since it carries the theme's brand identity.
|
||||
* Only picks foreground if it is colorful (saturation > threshold) AND
|
||||
* has significantly better contrast (> 1.5x) against the QR background.
|
||||
*/
|
||||
function pickModuleColor(
|
||||
primary: { h: number; s: number; l: number },
|
||||
foreground: { h: number; s: number; l: number } | null,
|
||||
bgRgb: [number, number, number],
|
||||
): { h: number; s: number; l: number } {
|
||||
const fgIsColorful = foreground
|
||||
&& foreground.s >= COLORFUL_SAT_MIN
|
||||
&& foreground.l >= COLORFUL_L_MIN
|
||||
&& foreground.l <= COLORFUL_L_MAX;
|
||||
|
||||
if (!fgIsColorful) return primary;
|
||||
|
||||
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
|
||||
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
|
||||
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
|
||||
const fgContrast = getContrastRatio(fgRgb, bgRgb);
|
||||
|
||||
// Foreground must be significantly better to override primary
|
||||
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive QR module and background hex colors from the active theme.
|
||||
*
|
||||
* Light themes: white background, best themed color as modules (darkened if needed).
|
||||
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
|
||||
*
|
||||
* "Best themed color" is --primary by default. If --foreground is colorful
|
||||
* (saturation > 15%) and offers better contrast, it wins instead.
|
||||
*/
|
||||
function getThemedQRColors(): { dark: string; light: string } {
|
||||
const primary = readCssHsl('--primary');
|
||||
const foreground = readCssHsl('--foreground');
|
||||
const background = readCssHsl('--background');
|
||||
|
||||
if (!primary) return { dark: '#000000', light: '#ffffff' };
|
||||
|
||||
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
|
||||
|
||||
if (!isDark) {
|
||||
const white: [number, number, number] = [255, 255, 255];
|
||||
const module = pickModuleColor(primary, foreground, white);
|
||||
return { dark: darkenToContrast(module, white), light: '#ffffff' };
|
||||
}
|
||||
|
||||
if (!background) return { dark: '#ffffff', light: '#000000' };
|
||||
const bgRgb = hslToRgb(background.h, background.s, background.l);
|
||||
const module = pickModuleColor(primary, foreground, bgRgb);
|
||||
return {
|
||||
dark: lightenToContrast(module, bgRgb),
|
||||
light: rgbToHex(...bgRgb),
|
||||
};
|
||||
}
|
||||
|
||||
interface FollowQRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey ?? '');
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = user ? metadata?.name || genUserName(user.pubkey) : '';
|
||||
|
||||
const npub = user ? nip19.npubEncode(user.pubkey) : '';
|
||||
const followUrl = npub ? `${window.location.origin}/follow/${npub}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!followUrl || !open) return;
|
||||
|
||||
const { dark, light } = getThemedQRColors();
|
||||
|
||||
QRCode.toDataURL(followUrl, {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
.then(setQrDataUrl)
|
||||
.catch(console.error);
|
||||
}, [followUrl, open]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(followUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-6 flex flex-col items-center gap-5 rounded-2xl">
|
||||
<DialogTitle className="sr-only">Share follow link</DialogTitle>
|
||||
|
||||
{/* Avatar + name */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-16 ring-2 ring-secondary">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="text-xl font-semibold">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Scan to follow <span className="text-foreground font-medium">{displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Follow QR code"
|
||||
className="w-full rounded-xl border border-border"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square rounded-xl border border-border bg-muted animate-pulse" />
|
||||
)}
|
||||
|
||||
{/* Copy link */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary flex-shrink-0" />
|
||||
: <Copy className="size-3.5 flex-shrink-0" />}
|
||||
<span className="truncate max-w-64">{followUrl}</span>
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -125,7 +126,7 @@ export function ImageGallery({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{/* Lightbox (portals to document.body internally to escape stacking contexts) */}
|
||||
{lightboxIndex !== null && lightboxIndex !== undefined && (
|
||||
<Lightbox
|
||||
images={images}
|
||||
@@ -484,7 +485,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
(i) => i >= 0 && i < images.length,
|
||||
);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-[100] animate-in fade-in duration-200"
|
||||
@@ -582,7 +583,8 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
{bottomBar}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useAuthors } from "@/hooks/useAuthors";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
|
||||
import { useEncryptedSettings, getLocalSettingsSync } from "@/hooks/useEncryptedSettings";
|
||||
import { type SyncPhase, useInitialSync } from "@/hooks/useInitialSync";
|
||||
import { useLoginActions } from "@/hooks/useLoginActions";
|
||||
import { useNostrPublish } from "@/hooks/useNostrPublish";
|
||||
@@ -65,8 +65,12 @@ interface InitialSyncGateProps {
|
||||
export function InitialSyncGate({ children }: InitialSyncGateProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { phase, markComplete } = useInitialSync();
|
||||
const { isLoading: settingsLoading } = useEncryptedSettings();
|
||||
const [preloadApp, setPreloadApp] = useState(false);
|
||||
const [signupActive, setSignupActive] = useState(false);
|
||||
// Track whether we've shown the app at least once so we don't re-gate on
|
||||
// subsequent background refetches (e.g. window focus).
|
||||
const hasShownApp = useRef(false);
|
||||
|
||||
const startSignup = useCallback(() => setSignupActive(true), []);
|
||||
|
||||
@@ -91,8 +95,10 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Don't show sync/onboarding when logged out — just show the app
|
||||
// Don't show sync/onboarding when logged out — just show the app.
|
||||
// Reset hasShownApp so that re-login shows the spinner until settings load.
|
||||
if (!user) {
|
||||
hasShownApp.current = false;
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
@@ -121,6 +127,28 @@ export function InitialSyncGate({ children }: InitialSyncGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// For returning users (phase === "complete"), decide whether to gate:
|
||||
// - If we have a local lastSync timestamp, localStorage is trustworthy and
|
||||
// we can render immediately. NostrSync will hot-swap any differences in
|
||||
// the background once the remote settings arrive.
|
||||
// - If there's NO local timestamp (e.g. localStorage was cleared, or settings
|
||||
// were never synced on this browser), show the spinner until settings load
|
||||
// so the user sees correct state from the start.
|
||||
// Only gate on the very first load — once the app has been shown, don't
|
||||
// re-gate on background refetches (e.g. window focus).
|
||||
if (phase === "complete" && settingsLoading && !hasShownApp.current) {
|
||||
const hasLocalSync = user ? getLocalSettingsSync(user.pubkey) > 0 : false;
|
||||
if (!hasLocalSync) {
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
<SyncScreen phase="syncing" />
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
hasShownApp.current = true;
|
||||
|
||||
// idle or complete -> show app
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
UserPlus, LogOut,
|
||||
Loader2,
|
||||
Loader2, QrCode,
|
||||
} from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -15,6 +15,7 @@ import { SidebarNavList } from '@/components/SidebarNavItem';
|
||||
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
@@ -55,6 +56,7 @@ export function LeftSidebar() {
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
@@ -293,6 +295,10 @@ export function LeftSidebar() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-1">
|
||||
<button onClick={() => { setAccountPopoverOpen(false); setFollowQROpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
|
||||
<QrCode className="size-4 text-muted-foreground" />
|
||||
<span>Share profile</span>
|
||||
</button>
|
||||
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
|
||||
<UserPlus className="size-4 text-muted-foreground" />
|
||||
<span>Add another account</span>
|
||||
@@ -308,6 +314,7 @@ export function LeftSidebar() {
|
||||
)}
|
||||
|
||||
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,70 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Info, BookOpen, Shield, Code, ScrollText } from 'lucide-react';
|
||||
|
||||
interface LinkFooterProps {
|
||||
/** Optional callback fired when an internal (React Router) link is clicked. */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
const chipClass =
|
||||
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors';
|
||||
const iconClass = 'size-3 shrink-0';
|
||||
|
||||
/** Shared footer links used in both sidebars. */
|
||||
export function LinkFooter() {
|
||||
export function LinkFooter({ onNavigate }: LinkFooterProps) {
|
||||
return (
|
||||
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<footer className="mt-auto pt-3 pb-3 -mx-1 sidebar:bg-background/85 sidebar:rounded-xl sidebar:p-3">
|
||||
<nav className="flex items-center justify-center gap-0.5 flex-wrap" aria-label="Footer links">
|
||||
<a
|
||||
href="https://about.ditto.pub"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Info className={iconClass} />
|
||||
About
|
||||
</a>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://about.ditto.pub/docs/"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpen className={iconClass} />
|
||||
Docs
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/privacy" className="text-primary hover:underline">
|
||||
|
||||
<Link to="/privacy" className={chipClass} onClick={onNavigate}>
|
||||
<Shield className={iconClass} />
|
||||
Privacy
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://gitlab.com/soapbox-pub/ditto"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Code className={iconClass} />
|
||||
Source
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/changelog" className="text-primary hover:underline">
|
||||
|
||||
<Link to="/changelog" className={chipClass} onClick={onNavigate}>
|
||||
<ScrollText className={iconClass} />
|
||||
Changelog
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="text-xs leading-none" aria-hidden>🎭</span>
|
||||
Edit with Shakespeare
|
||||
</a>
|
||||
</p>
|
||||
</nav>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useState, useMemo, useCallback } from 'react';
|
||||
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { RightSidebar } from '@/components/RightSidebar';
|
||||
@@ -8,7 +8,7 @@ import { MobileBottomNav } from '@/components/MobileBottomNav';
|
||||
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { CursorFireEffect } from '@/components/CursorFireEffect';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -106,10 +106,12 @@ function MainLayoutInner() {
|
||||
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar, hideBottomNav } = useLayoutSnapshot();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
|
||||
const { config } = useAppContext();
|
||||
const { hidden: navHidden } = useScrollDirection(scrollContainer);
|
||||
|
||||
return (
|
||||
<CenterColumnContext.Provider value={centerColumnEl}>
|
||||
<DrawerContext.Provider value={openDrawer}>
|
||||
<NavHiddenContext.Provider value={navHidden}>
|
||||
{/* Magic Mouse fire particle overlay */}
|
||||
@@ -136,7 +138,10 @@ function MainLayoutInner() {
|
||||
being hidden. This depends on MobileTopBar having a transparent /
|
||||
semi-transparent background — a solid top bar would obscure the
|
||||
content underneath. Only active below the sidebar breakpoint. */}
|
||||
<div className={cn("relative flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}>
|
||||
<div
|
||||
ref={(el) => { centerColumnRef.current = el; setCenterColumnEl(el); }}
|
||||
className={cn("relative z-0 flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}
|
||||
>
|
||||
<Outlet />
|
||||
|
||||
{/* Desktop FAB — sticky within the feed column so it stays
|
||||
@@ -175,6 +180,7 @@ function MainLayoutInner() {
|
||||
)}
|
||||
</NavHiddenContext.Provider>
|
||||
</DrawerContext.Provider>
|
||||
</CenterColumnContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useId, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
@@ -11,6 +11,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
@@ -60,6 +61,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [accountExpanded, setAccountExpanded] = useState(false);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const { theme, customTheme, themes } = useTheme();
|
||||
|
||||
@@ -269,6 +271,13 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { handleClose(); setFollowQROpen(true); }}
|
||||
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<QrCode className="size-5 shrink-0" />
|
||||
<span>Share profile</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleClose(); setLoginDialogOpen(true); }}
|
||||
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
|
||||
@@ -318,8 +327,8 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<LinkFooter />
|
||||
<div className="px-2 safe-area-bottom">
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -362,8 +371,8 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<LinkFooter />
|
||||
<div className="px-2 safe-area-bottom">
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -376,6 +385,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
onLogin={() => setLoginDialogOpen(false)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
.filter(r => r.read)
|
||||
.map(r => r.url);
|
||||
|
||||
// Include zapstore relay for kind 32267 (apps) and 30063 (releases)
|
||||
const ZAPSTORE_KINDS = [32267, 30063];
|
||||
// Include zapstore relay for kind 32267 (apps), 30063 (releases), and 3063 (assets)
|
||||
const ZAPSTORE_KINDS = [32267, 30063, 3063];
|
||||
if (filters.every((f) => f?.kinds?.every((k) => ZAPSTORE_KINDS.includes(k)))) {
|
||||
return new Map([ZAPSTORE_RELAY, ...readRelays].map(url => [url, filters]));
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
|
||||
import { useEncryptedSettings, setLocalSettingsSync } from "@/hooks/useEncryptedSettings";
|
||||
import { isSyncDone } from "@/hooks/useInitialSync";
|
||||
import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
|
||||
import type { ThemeConfig } from "@/themes";
|
||||
import { themePresets } from "@/themes";
|
||||
|
||||
|
||||
/**
|
||||
* NostrSync - Syncs user's Nostr data
|
||||
@@ -246,16 +246,13 @@ export function NostrSync() {
|
||||
// Get the remote sync timestamp
|
||||
const remoteSync = encryptedSettings.lastSync || 0;
|
||||
|
||||
// On first load, seed the ref with the current remote timestamp so that
|
||||
// subsequent effect firings (e.g. after window focus) don't re-apply the
|
||||
// same snapshot. We still apply this snapshot below so that settings
|
||||
// restored from the query cache (seeded by useInitialSync) take effect
|
||||
// immediately on page reload without waiting for the 5-second delay.
|
||||
// On first load, mark seeded so this block only runs once.
|
||||
// We intentionally do NOT pre-set lastSyncedTimestamp here — leaving it
|
||||
// at 0 lets the `remoteSync <= lastSyncedTimestamp` guard below fall
|
||||
// through so the settings are actually applied on this first pass.
|
||||
// Line 277 then records the timestamp to prevent re-application.
|
||||
if (!seededTimestamp) {
|
||||
lastSyncedTimestamp.current = remoteSync;
|
||||
setSeededTimestamp(true);
|
||||
// Fall through — apply the settings this time so that sidebarOrder
|
||||
// and other fields are always applied on the first load.
|
||||
}
|
||||
|
||||
// Don't overwrite local config if we just saved settings (short-circuit for
|
||||
@@ -285,19 +282,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;
|
||||
}
|
||||
@@ -446,6 +431,12 @@ export function NostrSync() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the sync timestamp so the next page load can render immediately
|
||||
// from localStorage without showing the spinner.
|
||||
if (user && remoteSync > 0) {
|
||||
setLocalSettingsSync(user.pubkey, remoteSync);
|
||||
}
|
||||
}, [
|
||||
user,
|
||||
encryptedSettings,
|
||||
|
||||
+249
-381
@@ -21,7 +21,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactNode, lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */
|
||||
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
|
||||
@@ -74,6 +74,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";
|
||||
@@ -96,7 +98,8 @@ import { genUserName } from "@/lib/genUserName";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
|
||||
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
|
||||
import { getParentEventId, isReplyEvent } from "@/lib/nostrEvents";
|
||||
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
|
||||
import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
|
||||
import { isSingleImagePost } from "@/lib/noteContent";
|
||||
import { shareOrCopy } from "@/lib/share";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
@@ -116,6 +119,113 @@ function ProfileCardContent({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Shared activity card shell for reaction / repost / zap / poll vote ──── */
|
||||
|
||||
interface ActivityCardProps {
|
||||
/** The round element in the left column (icon bubble or avatar). */
|
||||
icon: ReactNode;
|
||||
/** The actor row content (avatar + name + label + timestamp). */
|
||||
actorRow: ReactNode;
|
||||
/** Optional extra content below the actor row (zap message, vote label, etc.). */
|
||||
children?: ReactNode;
|
||||
/** Threaded mode: connector line below icon, no bottom border. */
|
||||
threaded?: boolean;
|
||||
/** Last item in thread — no connector line, has bottom border. */
|
||||
threadedLast?: boolean;
|
||||
/** Custom connector line class. */
|
||||
threadedLineClassName?: string;
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler;
|
||||
onAuxClick?: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
export function ActivityCard({
|
||||
icon,
|
||||
actorRow,
|
||||
children,
|
||||
threaded,
|
||||
threadedLast,
|
||||
threadedLineClassName,
|
||||
className,
|
||||
onClick,
|
||||
onAuxClick,
|
||||
}: ActivityCardProps) {
|
||||
const isThreaded = threaded || threadedLast;
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
isThreaded
|
||||
? cn("pt-3", threaded ? "pb-0" : "pb-3 border-b border-border")
|
||||
: "py-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
onAuxClick={onAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{icon}
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex-1 min-w-0", isThreaded ? "min-h-10 flex flex-col justify-center" : "", threaded && "pb-3")}>
|
||||
{actorRow}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable actor row: small avatar + display name + action label + timestamp. */
|
||||
export interface ActorRowProps {
|
||||
pubkey: string;
|
||||
profileUrl: string;
|
||||
avatarShape: Parameters<typeof Avatar>[0]['shape'];
|
||||
picture?: string;
|
||||
displayName: string;
|
||||
authorEvent?: NostrEvent;
|
||||
isLoading?: boolean;
|
||||
label: string;
|
||||
/** Extra inline elements after the label (e.g. zap amount). */
|
||||
extra?: ReactNode;
|
||||
/** Formatted timestamp string (e.g. timeAgo or full date). */
|
||||
timestampLabel: string;
|
||||
}
|
||||
|
||||
export function ActorRow({ pubkey, profileUrl, avatarShape, picture, displayName, authorEvent, isLoading, label, extra, timestampLabel }: ActorRowProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
|
||||
{authorEvent ? <EmojifiedText tags={authorEvent.tags}>{displayName}</EmojifiedText> : displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{label}</span>
|
||||
{extra}
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timestampLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoteCardProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
@@ -217,6 +327,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
const zapSenderName = getDisplayName(zapSenderMeta, zapSenderPubkey);
|
||||
const zapSenderUrl = useProfileUrl(zapSenderPubkey, zapSenderMeta);
|
||||
|
||||
const pollVoteLabel = usePollVoteLabel(event);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
@@ -247,7 +359,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
target.closest("[data-radix-dialog-content]") ||
|
||||
target.closest("[data-vaul-drawer]") ||
|
||||
target.closest("[data-vaul-drawer-overlay]") ||
|
||||
target.closest('[data-testid="zap-modal"]')
|
||||
target.closest('[data-testid="zap-modal"]') ||
|
||||
target.closest("button") ||
|
||||
target.closest("a")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -262,7 +376,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
target.closest("[data-radix-dialog-content]") ||
|
||||
target.closest("[data-vaul-drawer]") ||
|
||||
target.closest("[data-vaul-drawer-overlay]") ||
|
||||
target.closest('[data-testid="zap-modal"]')
|
||||
target.closest('[data-testid="zap-modal"]') ||
|
||||
target.closest("button") ||
|
||||
target.closest("a")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -289,6 +405,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isProfileBadges = event.kind === 10008 || event.kind === 30008;
|
||||
const isBadge = isBadgeDefinition || isProfileBadges;
|
||||
const isReaction = event.kind === 7;
|
||||
const isPollVote = event.kind === 1018;
|
||||
const isRepost = event.kind === 6 || event.kind === 16;
|
||||
const isPhoto = event.kind === 20;
|
||||
const isNormalVideo = event.kind === 21;
|
||||
@@ -306,6 +423,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;
|
||||
@@ -330,12 +450,16 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isEmojiPack &&
|
||||
!isBadge &&
|
||||
!isReaction &&
|
||||
!isPollVote &&
|
||||
!isRepost &&
|
||||
!isPhoto &&
|
||||
!isVideo &&
|
||||
!isAudioKind &&
|
||||
!isDevKind &&
|
||||
!isZapstoreApp &&
|
||||
!isZapstoreRelease &&
|
||||
!isZapstoreAsset &&
|
||||
!isAppHandler &&
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
!isVanish &&
|
||||
@@ -412,11 +536,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
return [parentAuthor];
|
||||
}, [event.tags, isTextNote, isReply, event.pubkey]);
|
||||
|
||||
// Extract the parent event ID for reply hover card preview
|
||||
const parentEventId = useMemo(() => {
|
||||
// Extract the parent event ID + relay/author hints for reply hover card preview
|
||||
const parentHints = useMemo(() => {
|
||||
if (!isReply) return undefined;
|
||||
return getParentEventId(event);
|
||||
return getParentEventHints(event);
|
||||
}, [event, isReply]);
|
||||
const parentEventId = parentHints?.id;
|
||||
|
||||
// Kind 34236 specific
|
||||
const imeta = useMemo(
|
||||
@@ -458,7 +583,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
{/* Reply context (kind 1) or comment context (kind 1111) — shown above content */}
|
||||
{isComment && <CommentContext event={event} />}
|
||||
{isReply && (
|
||||
<ReplyContext pubkeys={replyToPubkeys} parentEventId={parentEventId} />
|
||||
<ReplyContext
|
||||
pubkeys={replyToPubkeys}
|
||||
parentEventId={parentEventId}
|
||||
parentRelayHint={parentHints?.relayHint}
|
||||
parentAuthorHint={parentHints?.authorHint}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content — kind-based dispatch, guarded by NIP-36 content-warning */}
|
||||
@@ -531,7 +661,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 ? (
|
||||
@@ -772,399 +920,107 @@ export const NoteCard = memo(function NoteCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Reaction layout (kind 7) — compact activity-style card ──
|
||||
// ── Reaction layout (kind 7) ──
|
||||
if (isReaction) {
|
||||
// Threaded reaction (used in AncestorThread with connector line)
|
||||
if (threaded || threadedLast) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
threaded ? "pb-0" : "pb-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Reaction emoji bubble instead of avatar */}
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
|
||||
<ReactionEmoji
|
||||
content={event.content}
|
||||
tags={event.tags}
|
||||
className="text-lg leading-none"
|
||||
/>
|
||||
</div>
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex items-center min-h-10",
|
||||
threaded && "pb-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage
|
||||
src={metadata?.picture}
|
||||
alt={displayName}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">reacted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal reaction card (standalone or in feed)
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Large reaction emoji */}
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0">
|
||||
<ReactionEmoji
|
||||
content={event.content}
|
||||
tags={event.tags}
|
||||
className="text-xl leading-none"
|
||||
/>
|
||||
<ActivityCard
|
||||
icon={
|
||||
<div className={cn("flex items-center justify-center rounded-full bg-pink-500/10 shrink-0 text-lg leading-none", iconSize)}>
|
||||
<ReactionEmoji content={event.content} tags={event.tags} className="h-5 w-5 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Author + "reacted" label */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground">reacted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
|
||||
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reacted" timestampLabel={timeAgo(event.created_at)} />
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Repost layout (kind 6 / 16) — compact activity-style card ──
|
||||
// ── Repost layout (kind 6 / 16) ──
|
||||
if (isRepost) {
|
||||
// Threaded repost (used in AncestorThread with connector line)
|
||||
if (threaded || threadedLast) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
threaded ? "pb-0" : "pb-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Repost icon bubble instead of avatar */}
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-accent/10 shrink-0">
|
||||
<RepostIcon className="size-5 text-accent" />
|
||||
</div>
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 flex items-center min-h-10",
|
||||
threaded && "pb-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage
|
||||
src={metadata?.picture}
|
||||
alt={displayName}
|
||||
/>
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
) : (
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">reposted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal repost card (standalone or in feed)
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Repost icon */}
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-accent/10 shrink-0">
|
||||
<ActivityCard
|
||||
icon={
|
||||
<div className={cn("flex items-center justify-center rounded-full bg-accent/10 shrink-0", iconSize)}>
|
||||
<RepostIcon className="size-5 text-accent" />
|
||||
</div>
|
||||
|
||||
{/* Author + "reposted" label */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-6">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="font-semibold text-sm hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>
|
||||
{displayName}
|
||||
</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground">reposted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={event.pubkey} profileUrl={profileUrl} avatarShape={avatarShape} picture={metadata?.picture}
|
||||
displayName={displayName} authorEvent={author.data?.event} isLoading={author.isLoading} label="reposted" timestampLabel={timeAgo(event.created_at)} />
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zap receipt layout (kind 9735) — mirrors reaction layout exactly ──
|
||||
// ── Zap receipt layout (kind 9735) ──
|
||||
if (isZap) {
|
||||
const zapAmountSats = Math.floor(extractZapAmount(event) / 1000);
|
||||
const zapMessage = extractZapMessage(event);
|
||||
|
||||
const zapActorRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
{zapSender.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-6 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{zapSenderPubkey && (
|
||||
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
|
||||
<Link to={zapSenderUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={zapSenderShape} className="size-6">
|
||||
<AvatarImage src={zapSenderMeta?.picture} alt={zapSenderName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">{zapSenderName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
{zapSenderPubkey && (
|
||||
<ProfileHoverCard pubkey={zapSenderPubkey} asChild>
|
||||
<Link to={zapSenderUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
|
||||
{zapSender.data?.event ? <EmojifiedText tags={zapSender.data.event.tags}>{zapSenderName}</EmojifiedText> : zapSenderName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground shrink-0">zapped</span>
|
||||
{zapAmountSats > 0 && (
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<ActivityCard
|
||||
icon={
|
||||
<div className={cn("flex items-center justify-center rounded-full bg-amber-500/10 shrink-0", iconSize)}>
|
||||
<Zap className="size-5 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
}
|
||||
actorRow={
|
||||
<ActorRow pubkey={zapSenderPubkey} profileUrl={zapSenderUrl} avatarShape={zapSenderShape} picture={zapSenderMeta?.picture}
|
||||
displayName={zapSenderName} authorEvent={zapSender.data?.event} isLoading={zapSender.isLoading} label="zapped" timestampLabel={timeAgo(event.created_at)}
|
||||
extra={zapAmountSats > 0 ? (
|
||||
<span className="text-sm font-semibold text-amber-500 shrink-0">
|
||||
{formatNumber(zapAmountSats)} {zapAmountSats === 1 ? 'sat' : 'sats'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (threaded || threadedLast) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 pt-3 hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
threaded ? "pb-0" : "pb-3 border-b border-border",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-amber-500/10 shrink-0">
|
||||
<Zap className="size-5 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
{threaded && <div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />}
|
||||
</div>
|
||||
<div className={cn("flex-1 min-w-0 flex flex-col justify-center min-h-10", threaded && "pb-3")}>
|
||||
{zapActorRow}
|
||||
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">“{zapMessage}”</p>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
) : undefined}
|
||||
/>
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-amber-500/10 shrink-0">
|
||||
<Zap className="size-5 text-amber-500 fill-amber-500" />
|
||||
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">“{zapMessage}”</p>}
|
||||
</ActivityCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Poll vote layout (kind 1018) ──
|
||||
if (isPollVote) {
|
||||
const iconSize = threaded || threadedLast ? "size-10" : "size-11";
|
||||
return (
|
||||
<ActivityCard
|
||||
icon={
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className={iconSize}>
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
}
|
||||
actorRow={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ProfileHoverCard pubkey={event.pubkey} asChild>
|
||||
<Link to={profileUrl} className="font-semibold text-sm hover:underline truncate" onClick={(e) => e.stopPropagation()}>
|
||||
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText> : displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="text-sm text-muted-foreground shrink-0">voted</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{zapActorRow}
|
||||
{zapMessage && <p className="text-xs text-muted-foreground italic mt-1">“{zapMessage}”</p>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
threaded={threaded} threadedLast={threadedLast} threadedLineClassName={threadedLineClassName}
|
||||
className={className} onClick={handleCardClick} onAuxClick={handleAuxClick}
|
||||
>
|
||||
{pollVoteLabel && <p className="text-sm font-semibold mt-0.5 truncate">{pollVoteLabel}</p>}
|
||||
</ActivityCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1999,6 +1855,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,
|
||||
);
|
||||
}
|
||||
+362
-22
@@ -1,10 +1,22 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { BarChart3, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BarChart3, CheckCircle2, Clock, X, ChevronRight } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -61,6 +73,61 @@ function tallyVotes(
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Get voter events for a specific option ID. */
|
||||
function getVotersForOption(
|
||||
votes: NostrEvent[],
|
||||
optionId: string,
|
||||
pollType: string,
|
||||
): NostrEvent[] {
|
||||
return votes.filter((vote) => {
|
||||
const responseTags = vote.tags.filter(([n]) => n === 'response');
|
||||
if (pollType === 'singlechoice') {
|
||||
return responseTags[0]?.[1] === optionId;
|
||||
} else {
|
||||
return responseTags.some(([, id]) => id === optionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Clickable avatar stack + "N votes" label. */
|
||||
function VoterAvatarsButton({
|
||||
votes,
|
||||
totalVotes,
|
||||
authorsMap,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
votes: NostrEvent[];
|
||||
totalVotes: number;
|
||||
authorsMap?: Map<string, { pubkey: string; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} className={cn('flex items-center gap-1.5 group', className)}>
|
||||
<div className="flex -space-x-1.5">
|
||||
{votes.slice(0, 6).map((vote) => {
|
||||
const authorData = authorsMap?.get(vote.pubkey);
|
||||
const metadata = authorData?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const name = metadata?.name || genUserName(vote.pubkey);
|
||||
return (
|
||||
<Avatar key={vote.pubkey} shape={avatarShape} className="size-5 ring-1 ring-background">
|
||||
<AvatarImage src={metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PollContent({ event }: { event: NostrEvent }) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
@@ -72,6 +139,10 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
const endsAt = getTag(event.tags, 'endsAt');
|
||||
const isExpired = endsAt ? Number(endsAt) < Math.floor(Date.now() / 1000) : false;
|
||||
|
||||
// Modal state
|
||||
const [votersModalOpen, setVotersModalOpen] = useState(false);
|
||||
const [votersModalOptionId, setVotersModalOptionId] = useState<string | null>(null);
|
||||
|
||||
// Fetch vote events
|
||||
const { data: votes } = useQuery<NostrEvent[]>({
|
||||
queryKey: ['poll-votes', event.id],
|
||||
@@ -126,6 +197,19 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
});
|
||||
};
|
||||
|
||||
// Collect all voter pubkeys for batch profile fetching
|
||||
const allVoterPubkeys = useMemo(() => {
|
||||
if (!votes) return [];
|
||||
return votes.map((v) => v.pubkey);
|
||||
}, [votes]);
|
||||
|
||||
const { data: authorsMap } = useAuthors(allVoterPubkeys);
|
||||
|
||||
const openVotersModal = (optionId: string | null) => {
|
||||
setVotersModalOptionId(optionId);
|
||||
setVotersModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Question */}
|
||||
@@ -133,7 +217,7 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
<NoteContent event={event} />
|
||||
</div>
|
||||
|
||||
{/* Poll type + expiry badges */}
|
||||
{/* Poll type + expiry badges + voter avatars + vote count */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground bg-secondary/60 px-2 py-0.5 rounded-full">
|
||||
<BarChart3 className="size-3" />
|
||||
@@ -145,6 +229,17 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
Ended
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Voter avatars + count pushed to the right */}
|
||||
{showResults && totalVotes > 0 && (
|
||||
<VoterAvatarsButton
|
||||
votes={votes ?? []}
|
||||
totalVotes={totalVotes}
|
||||
authorsMap={authorsMap}
|
||||
onClick={() => openVotersModal(null)}
|
||||
className="ml-auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -192,26 +287,271 @@ export function PollContent({ event }: { event: NostrEvent }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Vote button or total */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
{!showResults && user && (
|
||||
<button
|
||||
onClick={handleVote}
|
||||
disabled={!selectedOption}
|
||||
className={cn(
|
||||
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
|
||||
selectedOption
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-secondary text-muted-foreground cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
Vote
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Vote button + voter avatars (voting mode only) */}
|
||||
{!showResults && (
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
{totalVotes > 0 ? (
|
||||
<VoterAvatarsButton
|
||||
votes={votes ?? []}
|
||||
totalVotes={totalVotes}
|
||||
authorsMap={authorsMap}
|
||||
onClick={() => openVotersModal(null)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">0 votes</span>
|
||||
)}
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleVote}
|
||||
disabled={!selectedOption}
|
||||
className={cn(
|
||||
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
|
||||
selectedOption
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-secondary text-muted-foreground cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
Vote
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voters Modal */}
|
||||
<PollVotersModal
|
||||
open={votersModalOpen}
|
||||
onOpenChange={setVotersModalOpen}
|
||||
allVotes={votes ?? []}
|
||||
options={options}
|
||||
pollType={pollType}
|
||||
initialOptionId={votersModalOptionId}
|
||||
authorsMap={authorsMap}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Poll Voters Modal ──── */
|
||||
|
||||
interface PollVotersModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
allVotes: NostrEvent[];
|
||||
options: PollOption[];
|
||||
pollType: string;
|
||||
initialOptionId?: string | null;
|
||||
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
||||
}
|
||||
|
||||
function PollVotersModal({ open, onOpenChange, allVotes, options, pollType, initialOptionId, authorsMap }: PollVotersModalProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<string | null>(initialOptionId ?? null);
|
||||
|
||||
// Sync filter when modal opens with a specific option
|
||||
useMemo(() => {
|
||||
if (open) setActiveFilter(initialOptionId ?? null);
|
||||
}, [open, initialOptionId]);
|
||||
|
||||
// Build a map from option ID to label for display
|
||||
const optionLabelMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const opt of options) {
|
||||
map.set(opt.id, opt.label);
|
||||
}
|
||||
return map;
|
||||
}, [options]);
|
||||
|
||||
// Filter voters based on active filter
|
||||
const filteredVoters = useMemo(() => {
|
||||
if (activeFilter === null) return allVotes;
|
||||
return getVotersForOption(allVotes, activeFilter, pollType);
|
||||
}, [allVotes, activeFilter, pollType]);
|
||||
|
||||
// Tally per option for the count badges
|
||||
const tally = useMemo(() => tallyVotes(allVotes, pollType), [allVotes, pollType]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[460px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 h-12">
|
||||
<DialogTitle className="text-base font-semibold">Voters</DialogTitle>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Option filter bars — scrollable when more than 3 */}
|
||||
<ScrollArea className={cn('px-4', options.length > 2 && 'max-h-[120px]')}>
|
||||
<div className="space-y-1.5">
|
||||
{/* "All" bar */}
|
||||
<button
|
||||
onClick={() => setActiveFilter(null)}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
|
||||
activeFilter === null ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-all duration-500',
|
||||
activeFilter === null ? 'bg-primary/15' : 'bg-secondary/40',
|
||||
)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div className="relative flex items-center justify-between px-3 py-2">
|
||||
<span className={cn('text-sm', activeFilter === null && 'font-semibold')}>All</span>
|
||||
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
|
||||
{allVotes.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Per-option bars */}
|
||||
{options.map((opt) => {
|
||||
const count = tally.get(opt.id) ?? 0;
|
||||
const pct = allVotes.length > 0 ? Math.round((count / allVotes.length) * 100) : 0;
|
||||
const isActive = activeFilter === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setActiveFilter(opt.id)}
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
|
||||
isActive ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 transition-all duration-500',
|
||||
isActive ? 'bg-primary/15' : 'bg-secondary/40',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="relative flex items-center justify-between px-3 py-2">
|
||||
<span className={cn('text-sm break-words min-w-0', isActive && 'font-semibold')}>{opt.label}</span>
|
||||
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Primary accent divider — only when scrollbox is active */}
|
||||
{options.length > 2 && <div className="mx-4 h-1 bg-primary rounded-full" />}
|
||||
|
||||
{/* Voter list */}
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{filteredVoters.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No votes yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{filteredVoters.map((vote) => (
|
||||
<VoterRow
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
optionLabelMap={optionLabelMap}
|
||||
pollType={pollType}
|
||||
authorsMap={authorsMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──── Voter Row ──── */
|
||||
|
||||
interface VoterRowProps {
|
||||
vote: NostrEvent;
|
||||
optionLabelMap: Map<string, string>;
|
||||
pollType: string;
|
||||
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
||||
}
|
||||
|
||||
function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps) {
|
||||
// Use batch-fetched author data if available, fall back to individual fetch
|
||||
const individualAuthor = useAuthor(authorsMap?.has(vote.pubkey) ? undefined : vote.pubkey);
|
||||
const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data;
|
||||
const metadata = authorData?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(vote.pubkey);
|
||||
|
||||
const nevent = useMemo(
|
||||
() => nip19.neventEncode({ id: vote.id, author: vote.pubkey }),
|
||||
[vote.id, vote.pubkey],
|
||||
);
|
||||
|
||||
// Resolve which option(s) this person voted for
|
||||
const votedOptions = useMemo(() => {
|
||||
const responseTags = vote.tags.filter(([n]) => n === 'response');
|
||||
if (pollType === 'singlechoice') {
|
||||
const id = responseTags[0]?.[1];
|
||||
const label = id ? optionLabelMap.get(id) : undefined;
|
||||
return label ? [label] : [];
|
||||
}
|
||||
const labels: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [, id] of responseTags) {
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
const label = optionLabelMap.get(id);
|
||||
if (label) labels.push(label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}, [vote.tags, pollType, optionLabelMap]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${nevent}`}
|
||||
onClick={() => {
|
||||
// Close any open dialogs by dispatching escape
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-sm truncate">
|
||||
{authorData?.event ? (
|
||||
<EmojifiedText tags={authorData.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
{metadata?.nip05 && (
|
||||
<VerifiedNip05Text nip05={metadata.nip05} pubkey={vote.pubkey} className="text-xs text-muted-foreground truncate" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{votedOptions.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{votedOptions.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(vote.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
import QRCode from 'qrcode';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getContentWarning } from '@/lib/contentWarning';
|
||||
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
|
||||
@@ -23,6 +25,12 @@ import { parseDimToAspectRatio } from '@/components/MediaCollage';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
|
||||
/** Media-native kinds shown in the sidebar (excludes kind 1 text notes and kind 1111 comments). */
|
||||
const SIDEBAR_MEDIA_KINDS = [20, 21, 22, 34236, 36787, 34139, 30054, 30055];
|
||||
|
||||
/** Maximum number of media tiles shown in the sidebar. */
|
||||
const SIDEBAR_MEDIA_LIMIT = 9;
|
||||
|
||||
/** Simple email regex for display purposes. */
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
@@ -65,10 +73,8 @@ interface ProfileField {
|
||||
|
||||
interface ProfileRightSidebarProps {
|
||||
fields?: ProfileField[];
|
||||
/** Media events fetched via a dedicated search query (video:true image:true). */
|
||||
mediaEvents?: NostrEvent[];
|
||||
/** Whether the media events are still loading. */
|
||||
mediaLoading?: boolean;
|
||||
/** Pubkey whose media-native events to display in the sidebar. */
|
||||
pubkey?: string;
|
||||
/** Called when a media tile is clicked. If provided, tiles don't navigate. */
|
||||
onMediaClick?: (url: string) => void;
|
||||
/** Override the root element's className (e.g. to show on mobile). */
|
||||
@@ -485,20 +491,49 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
const { config } = useAppContext();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Single query: fetch media-native events, then fill remaining slots with kind 1 media if needed.
|
||||
const { data: sidebarEvents, isPending: mediaLoading } = useQuery({
|
||||
queryKey: ['sidebar-media', pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const primaryEvents = await nostr.query(
|
||||
[{ kinds: SIDEBAR_MEDIA_KINDS, authors: [pubkey!], limit: SIDEBAR_MEDIA_LIMIT }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
const primary = primaryEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
// Only fetch kind 1 fallback if there aren't enough media-native events.
|
||||
if (primary.length >= SIDEBAR_MEDIA_LIMIT) return primary;
|
||||
|
||||
const fallbackEvents = await nostr.query(
|
||||
[{ kinds: [1], authors: [pubkey!], search: 'media:true', limit: SIDEBAR_MEDIA_LIMIT } as { kinds: number[]; authors: string[]; search: string; limit: number }],
|
||||
{ signal: querySignal },
|
||||
);
|
||||
const fallback = fallbackEvents.filter((e) => e.created_at <= now).sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return [...primary, ...fallback];
|
||||
},
|
||||
enabled: !!pubkey,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const media = useMemo(
|
||||
() => extractMedia(mediaEvents ?? [], config.contentWarningPolicy),
|
||||
[mediaEvents, config.contentWarningPolicy],
|
||||
() => extractMedia(sidebarEvents ?? [], config.contentWarningPolicy),
|
||||
[sidebarEvents, config.contentWarningPolicy],
|
||||
);
|
||||
const mediaLoading = mediaLoadingProp ?? false;
|
||||
|
||||
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
|
||||
|
||||
return (
|
||||
<aside className={cn("w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
|
||||
{/* Media Section — only shown when mediaEvents prop is provided */}
|
||||
{mediaEvents !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
{/* Media Section — only shown when pubkey prop is provided */}
|
||||
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
|
||||
{mediaLoading ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -608,7 +643,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo
|
||||
)}
|
||||
|
||||
{/* Footer — hidden when used as a fields-only preview */}
|
||||
{mediaEvents !== undefined && <LinkFooter />}
|
||||
{pubkey !== undefined && <LinkFooter />}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
|
||||
import { isCustomEmoji } from '@/lib/customEmoji';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
@@ -212,12 +213,10 @@ export function QuickReactMenu({
|
||||
title={`React with ${isCustom ? shortcode : emoji}`}
|
||||
>
|
||||
{customUrl ? (
|
||||
<img
|
||||
src={customUrl}
|
||||
alt={emoji}
|
||||
<CustomEmojiImg
|
||||
name={shortcode ?? emoji}
|
||||
url={customUrl}
|
||||
className="size-6 object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
emoji
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,10 @@ interface ReplyContextProps {
|
||||
pubkeys: string[];
|
||||
/** Hex event ID of the parent post being replied to. */
|
||||
parentEventId?: string;
|
||||
/** Relay URL hint for fetching the parent event. */
|
||||
parentRelayHint?: string;
|
||||
/** Author pubkey hint for NIP-65 outbox resolution of the parent event. */
|
||||
parentAuthorHint?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +24,7 @@ interface ReplyContextProps {
|
||||
* When parentEventId is provided, hovering over the line shows an embedded preview of the parent post.
|
||||
* Used consistently across NoteCard and notification views.
|
||||
*/
|
||||
export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContextProps) {
|
||||
export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAuthorHint, className }: ReplyContextProps) {
|
||||
// Filter out any undefined/empty pubkeys defensively
|
||||
const validPubkeys = pubkeys.filter(Boolean);
|
||||
// Show max 2 authors for cleaner UI
|
||||
@@ -38,7 +42,13 @@ export function ReplyContext({ pubkeys, parentEventId, className }: ReplyContext
|
||||
className="w-80 p-0 rounded-2xl shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EmbeddedNote eventId={parentEventId} className="border-0 rounded-none" disableHoverCards />
|
||||
<EmbeddedNote
|
||||
eventId={parentEventId}
|
||||
relays={parentRelayHint ? [parentRelayHint] : undefined}
|
||||
authorHint={parentAuthorHint}
|
||||
className="border-0 rounded-none"
|
||||
disableHoverCards
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Sticker, Info } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
|
||||
interface StickerPickerProps {
|
||||
@@ -54,11 +55,10 @@ export function StickerPicker({ onSelect }: StickerPickerProps) {
|
||||
onClick={() => onSelect(emoji)}
|
||||
className="aspect-square rounded-xl overflow-hidden hover:bg-muted/80 transition-all p-1.5 group active:scale-90"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -63,7 +63,7 @@ const AlertDialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[250] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg rounded-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -71,7 +71,7 @@ const DialogFooter = ({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -188,7 +188,7 @@ export interface AppConfig {
|
||||
homePage: string;
|
||||
/** Display name used in the NIP-89 "client" tag. Falls back to `appName` when not set. */
|
||||
clientName?: string;
|
||||
/** NIP-89 addr (`31990:<pubkey>:<d-tag>`) identifying this client's handler event. Included as the third element of the "client" tag. */
|
||||
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990:<pubkey>:<d-tag>` addr and relay hint for the "client" tag per NIP-89. */
|
||||
client?: string;
|
||||
/** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
|
||||
magicMouse: boolean;
|
||||
|
||||
@@ -112,6 +112,17 @@ export class LayoutStore {
|
||||
|
||||
export const LayoutStoreContext = createContext<LayoutStore | null>(null);
|
||||
|
||||
/**
|
||||
* Provides the center column DOM element so components deep in the tree can
|
||||
* portal overlays into it (e.g. the nsite preview panel).
|
||||
*/
|
||||
export const CenterColumnContext = createContext<HTMLElement | null>(null);
|
||||
|
||||
/** Hook to get the center column DOM element. Returns null until the layout has mounted. */
|
||||
export function useCenterColumn(): HTMLElement | null {
|
||||
return useContext(CenterColumnContext);
|
||||
}
|
||||
|
||||
/** Context for exposing the scroll-direction hidden state to child components (MobileTopBar, SubHeaderBar). */
|
||||
export const NavHiddenContext = createContext<boolean>(false);
|
||||
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { toast } from './useToast';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
buildMigrationTags,
|
||||
generatePetId10,
|
||||
getCanonicalBlobbiD,
|
||||
migratePetInHas,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbiEvent,
|
||||
parseStorageTags,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
type StorageItem,
|
||||
} from '@/lib/blobbi';
|
||||
|
||||
/**
|
||||
* Result of a successful migration.
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
/** The new canonical d-tag */
|
||||
canonicalD: string;
|
||||
/** The published canonical Blobbi event */
|
||||
event: NostrEvent;
|
||||
/** The parsed canonical BlobbiCompanion */
|
||||
companion: BlobbiCompanion;
|
||||
/** The updated profile event */
|
||||
profileEvent: NostrEvent;
|
||||
/** The updated profile tags (canonical has, current_companion, etc.) */
|
||||
profileTags: string[][];
|
||||
/** The profile storage (unchanged during migration, but fresh from migrated profile) */
|
||||
profileStorage: StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the migration helper.
|
||||
*/
|
||||
export interface EnsureCanonicalOptions {
|
||||
/** The companion to check/migrate */
|
||||
companion: BlobbiCompanion;
|
||||
/** The user's profile */
|
||||
profile: BlobbonautProfile;
|
||||
/** Callback to update the profile event in query cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update the companion event in query cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update localStorage selection if it was pointing to legacy d */
|
||||
updateStoredSelectedD?: (newD: string) => void;
|
||||
/** Callback to invalidate companion query */
|
||||
invalidateCompanion?: () => void;
|
||||
/** Callback to invalidate profile query */
|
||||
invalidateProfile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of ensureCanonicalBlobbiBeforeAction.
|
||||
*/
|
||||
export interface EnsureCanonicalResult {
|
||||
/** Whether the companion was migrated */
|
||||
wasMigrated: boolean;
|
||||
/** The canonical companion (either the original or the migrated one) */
|
||||
companion: BlobbiCompanion;
|
||||
/** The canonical event tags to use for the action */
|
||||
allTags: string[][];
|
||||
/** The event content to use */
|
||||
content: string;
|
||||
/**
|
||||
* The latest profile tags to use for profile updates.
|
||||
* IMPORTANT: Always use these instead of profile.allTags from hook closure
|
||||
* to avoid restoring stale/legacy values after migration.
|
||||
*/
|
||||
profileAllTags: string[][];
|
||||
/**
|
||||
* The latest profile storage to use.
|
||||
* Use this as the base for storage modifications.
|
||||
*/
|
||||
profileStorage: StorageItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing centralized migration logic for Blobbi companions.
|
||||
*
|
||||
* This hook should be used by all action handlers to ensure legacy Blobbis
|
||||
* are automatically migrated before any interaction.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
|
||||
*
|
||||
* const handleFeed = async () => {
|
||||
* const result = await ensureCanonicalBlobbiBeforeAction({
|
||||
* companion,
|
||||
* profile,
|
||||
* updateProfileEvent,
|
||||
* updateCompanionEvent,
|
||||
* updateStoredSelectedD: setStoredSelectedD,
|
||||
* });
|
||||
*
|
||||
* if (!result) return; // Migration failed
|
||||
*
|
||||
* // Continue with the action using result.companion and result.allTags
|
||||
* const newTags = updateBlobbiTags(result.allTags, { ... });
|
||||
* // ... publish event
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function useBlobbiMigration() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Migrate a legacy Blobbi to canonical format.
|
||||
*
|
||||
* This function:
|
||||
* 1. Generates a canonical d-tag
|
||||
* 2. Ensures a seed exists (generates one if missing)
|
||||
* 3. Preserves name, stage, stats, state, timestamps
|
||||
* 4. Publishes a canonical 31124 event
|
||||
* 5. Updates the Blobbonaut profile (kind 11125)
|
||||
* 6. Updates local state (query cache, localStorage)
|
||||
*/
|
||||
const migrateLegacyBlobbi = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<MigrationResult | null> => {
|
||||
const {
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
} = options;
|
||||
|
||||
if (!user?.pubkey) {
|
||||
console.error('[Blobbi Migration] No user pubkey');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[Blobbi Migration] Starting migration for:', companion.d);
|
||||
|
||||
try {
|
||||
// Generate new canonical d-tag
|
||||
const newPetId = generatePetId10();
|
||||
const canonicalD = getCanonicalBlobbiD(user.pubkey, newPetId);
|
||||
|
||||
// Build migration tags (preserves name, stage, stats, generates seed if missing)
|
||||
const migrationTags = buildMigrationTags(companion.event, newPetId, user.pubkey);
|
||||
|
||||
console.log('[Blobbi Migration] Publishing canonical event with d:', canonicalD);
|
||||
|
||||
// Publish the canonical Blobbi state
|
||||
const canonicalEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content || `${companion.name} is a ${companion.stage} Blobbi.`,
|
||||
tags: migrationTags,
|
||||
});
|
||||
|
||||
// Parse the new event to get the canonical companion
|
||||
const canonicalCompanion = parseBlobbiEvent(canonicalEvent);
|
||||
if (!canonicalCompanion) {
|
||||
throw new Error('Failed to parse migrated event');
|
||||
}
|
||||
|
||||
// Update profile: replace legacy d with canonical d in has[], update current_companion
|
||||
const updatedHas = migratePetInHas(profile.has, companion.d, canonicalD);
|
||||
const shouldUpdateCurrentCompanion = profile.currentCompanion === companion.d;
|
||||
|
||||
const profileUpdates: Record<string, string | string[]> = {
|
||||
has: updatedHas,
|
||||
};
|
||||
|
||||
if (shouldUpdateCurrentCompanion) {
|
||||
profileUpdates.current_companion = canonicalD;
|
||||
}
|
||||
|
||||
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
|
||||
|
||||
console.log('[Blobbi Migration] Publishing updated profile');
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
// Update query caches
|
||||
updateProfileEvent(profileEvent);
|
||||
updateCompanionEvent(canonicalEvent);
|
||||
|
||||
// Update localStorage selection if it was pointing to legacy d
|
||||
if (updateStoredSelectedD) {
|
||||
console.log('[Blobbi Migration] Updating localStorage selection:', canonicalD);
|
||||
updateStoredSelectedD(canonicalD);
|
||||
}
|
||||
|
||||
// Invalidate queries to refetch fresh data
|
||||
invalidateCompanion?.();
|
||||
invalidateProfile?.();
|
||||
|
||||
toast({
|
||||
title: 'Pet upgraded!',
|
||||
description: `${companion.name} has been migrated to the new format.`,
|
||||
});
|
||||
|
||||
console.log('[Blobbi Migration] Migration complete:', {
|
||||
legacyD: companion.d,
|
||||
canonicalD,
|
||||
});
|
||||
|
||||
// Parse storage from the migrated profile tags
|
||||
// Storage itself doesn't change during migration, but we need fresh tags
|
||||
const migratedStorage = parseStorageTags(profileTags);
|
||||
|
||||
return {
|
||||
canonicalD,
|
||||
event: canonicalEvent,
|
||||
companion: canonicalCompanion,
|
||||
profileEvent,
|
||||
profileTags,
|
||||
profileStorage: migratedStorage,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Blobbi Migration] Migration failed:', error);
|
||||
toast({
|
||||
title: 'Migration failed',
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, [user?.pubkey, publishEvent]);
|
||||
|
||||
/**
|
||||
* Ensure a Blobbi is in canonical format before performing an action.
|
||||
*
|
||||
* If the companion is legacy, it will be migrated first.
|
||||
* Returns the canonical companion to use for the action.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if Blobbi is legacy
|
||||
* 2. If legacy: migrate Blobbi
|
||||
* 3. Return the resolved canonical Blobbi
|
||||
*
|
||||
* All interaction handlers should call this before publishing events.
|
||||
*/
|
||||
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<EnsureCanonicalResult | null> => {
|
||||
const { companion, profile } = options;
|
||||
|
||||
// Check if the companion needs migration
|
||||
if (companion.isLegacy) {
|
||||
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
|
||||
|
||||
const migrationResult = await migrateLegacyBlobbi(options);
|
||||
|
||||
if (!migrationResult) {
|
||||
// Migration failed, cannot proceed with action
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the canonical companion AND migrated profile context
|
||||
// CRITICAL: Consumers must use profileAllTags instead of profile.allTags
|
||||
// to avoid restoring stale/legacy values
|
||||
return {
|
||||
wasMigrated: true,
|
||||
companion: migrationResult.companion,
|
||||
allTags: migrationResult.event.tags,
|
||||
content: migrationResult.event.content,
|
||||
profileAllTags: migrationResult.profileTags,
|
||||
profileStorage: migrationResult.profileStorage,
|
||||
};
|
||||
}
|
||||
|
||||
// Companion is already canonical, return profile as-is
|
||||
return {
|
||||
wasMigrated: false,
|
||||
companion,
|
||||
allTags: companion.allTags,
|
||||
content: companion.event.content,
|
||||
profileAllTags: profile.allTags,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [migrateLegacyBlobbi]);
|
||||
|
||||
return {
|
||||
/** Migrate a legacy Blobbi to canonical format */
|
||||
migrateLegacyBlobbi,
|
||||
/** Ensure a Blobbi is canonical before an action, migrating if necessary */
|
||||
ensureCanonicalBlobbiBeforeAction,
|
||||
};
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
type BlobbiCompanion,
|
||||
} from '@/lib/blobbi';
|
||||
|
||||
/** Maximum number of d-tags per query chunk to avoid relay issues */
|
||||
const CHUNK_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Split an array into chunks of a given size.
|
||||
*/
|
||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch ALL Blobbi companions (Kind 31124) owned by the logged-in user.
|
||||
*
|
||||
* Features:
|
||||
* - Fetches ALL pets by d-tag list (no limit: 1)
|
||||
* - Chunks large d-lists into multiple queries for relay compatibility
|
||||
* - Keeps only the newest event per d-tag
|
||||
* - Returns both a lookup record and array of companions
|
||||
* - Provides invalidation and optimistic update helpers
|
||||
*/
|
||||
export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Create a stable query key based on sorted d-tags
|
||||
const sortedDList = useMemo(() => {
|
||||
if (!dList || dList.length === 0) return null;
|
||||
return [...dList].sort();
|
||||
}, [dList]);
|
||||
|
||||
const queryKeyDTags = sortedDList?.join(',') ?? '';
|
||||
|
||||
// Main query to fetch all companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !sortedDList || sortedDList.length === 0) {
|
||||
console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty');
|
||||
return { companionsByD: {}, companions: [] };
|
||||
}
|
||||
|
||||
// Log the dList we're about to query
|
||||
console.log('[Blobbi] dList:', sortedDList);
|
||||
|
||||
// Chunk the d-list for relay compatibility
|
||||
const chunks = chunkArray(sortedDList, CHUNK_SIZE);
|
||||
console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)');
|
||||
|
||||
// Query all chunks in parallel
|
||||
const allEvents: NostrEvent[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const filter = {
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
// IMPORTANT: No limit - fetch ALL pets matching the d-tags
|
||||
};
|
||||
|
||||
// Log the filter immediately before query
|
||||
console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2));
|
||||
|
||||
const events = await nostr.query([filter], { signal });
|
||||
allEvents.push(...events);
|
||||
|
||||
console.log('[useBlobbisCollection] Chunk returned', events.length, 'events');
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Total events received:', allEvents.length);
|
||||
|
||||
// Filter to valid events
|
||||
const validEvents = allEvents.filter(isValidBlobbiEvent);
|
||||
|
||||
console.log('[useBlobbisCollection] Valid events:', validEvents.length);
|
||||
|
||||
// Group events by d-tag and keep only the newest per d
|
||||
const eventsByD = new Map<string, NostrEvent>();
|
||||
|
||||
for (const event of validEvents) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (!dTag) continue;
|
||||
|
||||
const existing = eventsByD.get(dTag);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
eventsByD.set(dTag, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse all events into BlobbiCompanion objects
|
||||
const companionsByD: Record<string, BlobbiCompanion> = {};
|
||||
const companions: BlobbiCompanion[] = [];
|
||||
|
||||
for (const [dTag, event] of eventsByD) {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (parsed) {
|
||||
companionsByD[dTag] = parsed;
|
||||
companions.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Parsed companions:', {
|
||||
count: companions.length,
|
||||
dTags: Object.keys(companionsByD),
|
||||
});
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
staleTime: 30_000, // 30 seconds
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
});
|
||||
|
||||
// Helper to invalidate and refetch after publishing
|
||||
const invalidate = useCallback(() => {
|
||||
if (user?.pubkey && queryKeyDTags) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
});
|
||||
}
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Update a single companion event in the query cache (optimistic update)
|
||||
const updateCompanionEvent = useCallback((event: NostrEvent) => {
|
||||
const parsed = parseBlobbiEvent(event);
|
||||
if (!parsed || !user?.pubkey) return;
|
||||
|
||||
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
|
||||
['blobbi-collection', user.pubkey, queryKeyDTags],
|
||||
(prev) => {
|
||||
if (!prev) {
|
||||
return {
|
||||
companionsByD: { [parsed.d]: parsed },
|
||||
companions: [parsed],
|
||||
};
|
||||
}
|
||||
|
||||
// Update the specific companion in the record
|
||||
const newCompanionsByD = {
|
||||
...prev.companionsByD,
|
||||
[parsed.d]: parsed,
|
||||
};
|
||||
|
||||
// Rebuild companions array from the record
|
||||
const newCompanions = Object.values(newCompanionsByD);
|
||||
|
||||
return {
|
||||
companionsByD: newCompanionsByD,
|
||||
companions: newCompanions,
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [queryClient, user?.pubkey, queryKeyDTags]);
|
||||
|
||||
// Memoize return values for stability
|
||||
const companionsByD = query.data?.companionsByD ?? {};
|
||||
const companions = query.data?.companions ?? [];
|
||||
|
||||
return {
|
||||
/** Record of companions keyed by d-tag */
|
||||
companionsByD,
|
||||
/** Array of all companions (newest per d-tag) */
|
||||
companions,
|
||||
/** True only when query is loading and no data available */
|
||||
isLoading: query.isLoading,
|
||||
/** True when actively fetching */
|
||||
isFetching: query.isFetching,
|
||||
/** True when data is stale */
|
||||
isStale: query.isStale,
|
||||
/** Query error if any */
|
||||
error: query.error,
|
||||
/** Invalidate and refetch the collection */
|
||||
invalidate,
|
||||
/** Optimistically update a single companion in the cache */
|
||||
updateCompanionEvent,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useNostrPublish } from './useNostrPublish';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
profileNeedsPettingLevelNormalization,
|
||||
profileNeedsOnboardingTagMigration,
|
||||
buildNormalizedProfileTags,
|
||||
isLegacyBlobbonautKind,
|
||||
type BlobbonautProfile,
|
||||
@@ -55,9 +56,10 @@ export function useBlobbonautProfileNormalization({
|
||||
// Check what normalization is needed
|
||||
const needsTagNormalization = profileNeedsPettingLevelNormalization(profile);
|
||||
const needsKindMigration = isLegacyBlobbonautKind(profile.event);
|
||||
const needsOnboardingMigration = profileNeedsOnboardingTagMigration(profile);
|
||||
|
||||
// If no normalization needed, mark as seen and return
|
||||
if (!needsTagNormalization && !needsKindMigration) {
|
||||
if (!needsTagNormalization && !needsKindMigration && !needsOnboardingMigration) {
|
||||
normalizedEventIds.current.add(profile.event.id);
|
||||
return;
|
||||
}
|
||||
@@ -68,6 +70,7 @@ export function useBlobbonautProfileNormalization({
|
||||
const reasons: string[] = [];
|
||||
if (needsTagNormalization) reasons.push('missing pettingLevel');
|
||||
if (needsKindMigration) reasons.push('legacy kind 31125 → 11125');
|
||||
if (needsOnboardingMigration) reasons.push('onboarding_done → blobbi_onboarding_done');
|
||||
|
||||
console.log(`[ProfileNormalization] Profile needs normalization: ${reasons.join(', ')}`);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,28 @@ import { EncryptedSettingsSchema } from '@/lib/schemas';
|
||||
*/
|
||||
let lastWriteTs: number = 0;
|
||||
|
||||
/**
|
||||
* Persist the lastSync timestamp from encrypted settings into localStorage
|
||||
* so that InitialSyncGate can decide whether to show a spinner on reload.
|
||||
* If a local timestamp exists, localStorage is trustworthy and the app can
|
||||
* render immediately while NostrSync fetches updates in the background.
|
||||
*/
|
||||
export function getLocalSettingsSync(pubkey: string): number {
|
||||
try {
|
||||
return Number(localStorage.getItem(`ditto:settings-lastSync:${pubkey}`)) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLocalSettingsSync(pubkey: string, lastSync: number): void {
|
||||
try {
|
||||
localStorage.setItem(`ditto:settings-lastSync:${pubkey}`, String(lastSync));
|
||||
} catch {
|
||||
// localStorage may not be available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete encrypted app settings stored in NIP-78
|
||||
*/
|
||||
@@ -112,10 +134,10 @@ export function useEncryptedSettings() {
|
||||
return events[0];
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: Infinity,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes — allows window-focus refetch to pick up cross-device changes
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
@@ -147,7 +169,7 @@ export function useEncryptedSettings() {
|
||||
}
|
||||
},
|
||||
enabled: !!query.data && !!user,
|
||||
staleTime: Infinity,
|
||||
staleTime: 0, // Always re-derive when the upstream event changes
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -216,6 +238,10 @@ export function useEncryptedSettings() {
|
||||
queryClient.setQueryData(['parsedSettings', signedEvent.id], updatedSettings);
|
||||
// Cache is now up to date — pending ref no longer needed
|
||||
pendingSettings.current = null;
|
||||
// Persist the sync timestamp so the next page load can skip the spinner
|
||||
if (user && updatedSettings.lastSync) {
|
||||
setLocalSettingsSync(user.pubkey, updatedSettings.lastSync);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import { ZAPSTORE_RELAY } from '@/lib/appRelays';
|
||||
|
||||
/** Kinds whose canonical home is the Zapstore relay. */
|
||||
const ZAPSTORE_KINDS = [32267, 30063];
|
||||
const ZAPSTORE_KINDS = [32267, 30063, 3063];
|
||||
|
||||
/**
|
||||
* Extract write relay URLs from a NIP-65 (kind 10002) relay list event.
|
||||
@@ -64,7 +64,7 @@ export function useEvent(eventId: string | undefined, relays?: string[], authorH
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent | null>({
|
||||
queryKey: ['event', eventId ?? ''],
|
||||
queryKey: ['event', eventId ?? '', relays ?? [], authorHint ?? ''],
|
||||
queryFn: async () => {
|
||||
if (!eventId) return null;
|
||||
const filter: NostrFilter[] = [{ ids: [eventId], limit: 1 }];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { EncryptedSettingsSchema } from "@/lib/schemas";
|
||||
import { useAppContext } from "./useAppContext";
|
||||
import { useCurrentUser } from "./useCurrentUser";
|
||||
import type { EncryptedSettings } from "./useEncryptedSettings";
|
||||
import { type EncryptedSettings, setLocalSettingsSync } from "./useEncryptedSettings";
|
||||
import {
|
||||
type MuteListItem,
|
||||
parseMuteTags,
|
||||
@@ -206,6 +206,9 @@ export function useInitialSync() {
|
||||
if (parsed.theme) {
|
||||
updates.theme = parsed.theme;
|
||||
}
|
||||
if (parsed.customTheme) {
|
||||
updates.customTheme = parsed.customTheme;
|
||||
}
|
||||
if (parsed.autoShareTheme !== undefined) {
|
||||
updates.autoShareTheme = parsed.autoShareTheme;
|
||||
}
|
||||
@@ -227,6 +230,15 @@ export function useInitialSync() {
|
||||
if (parsed.homePage) {
|
||||
updates.homePage = parsed.homePage;
|
||||
}
|
||||
if (parsed.corsProxy) {
|
||||
updates.corsProxy = parsed.corsProxy;
|
||||
}
|
||||
if (parsed.faviconUrl) {
|
||||
updates.faviconUrl = parsed.faviconUrl;
|
||||
}
|
||||
if (parsed.linkPreviewUrl) {
|
||||
updates.linkPreviewUrl = parsed.linkPreviewUrl;
|
||||
}
|
||||
|
||||
return updates;
|
||||
});
|
||||
@@ -242,6 +254,11 @@ export function useInitialSync() {
|
||||
parsed,
|
||||
);
|
||||
|
||||
// Persist the sync timestamp so future reloads can skip the spinner
|
||||
if (parsed.lastSync) {
|
||||
setLocalSettingsSync(user.pubkey, parsed.lastSync);
|
||||
}
|
||||
|
||||
foundSettings = true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Detects whether the virtual keyboard is likely open on mobile devices.
|
||||
*
|
||||
* Uses the Visual Viewport API to compare the visible viewport height against
|
||||
* the full layout viewport. When the keyboard slides up, `visualViewport.height`
|
||||
* shrinks while `window.innerHeight` stays the same (or changes minimally).
|
||||
*
|
||||
* A threshold of 0.75 (75%) is used — if the visible area is less than 75% of
|
||||
* the layout viewport, we assume the keyboard is open.
|
||||
*/
|
||||
export function useKeyboardVisible(): boolean {
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
const THRESHOLD = 0.75;
|
||||
|
||||
const check = () => {
|
||||
const ratio = vv.height / window.innerHeight;
|
||||
const visible = ratio < THRESHOLD;
|
||||
setIsKeyboardVisible(visible);
|
||||
};
|
||||
|
||||
vv.addEventListener('resize', check);
|
||||
check();
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener('resize', check);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isKeyboardVisible;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user