Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a5e72efd0 | |||
| 0f1021e0d3 | |||
| be65c659b2 | |||
| b64aa4b24a | |||
| f63d8943d8 | |||
| 517a72cce7 | |||
| 6fc68766c9 | |||
| ae81c13cc1 | |||
| 41358d27ce | |||
| ac8bffba23 | |||
| 748365de40 | |||
| 361f8b9506 | |||
| c1ec7a25ed | |||
| 272586d033 | |||
| c77c098843 | |||
| ea7afa94f7 | |||
| 0c29506402 | |||
| b0609e7877 | |||
| 946be28b81 | |||
| 89250c7472 | |||
| cfc7a0d31c | |||
| 21003e3aed | |||
| 93e8a6290f | |||
| 47831ffa64 | |||
| 1533420320 | |||
| e3ef542875 | |||
| 3bf55990c0 | |||
| 283b31813c | |||
| 6e1197a067 | |||
| b7d1fbf860 | |||
| 8fde660075 | |||
| 50c7d67928 | |||
| e355c43925 | |||
| 696204870d | |||
| 0a7e01d17c | |||
| dd87bc96ec | |||
| a12d5db560 | |||
| 614634789c | |||
| 29696fa3d3 | |||
| ffc31e8e8f | |||
| 720a7e91fe | |||
| 05096e2cd9 | |||
| 05667460eb | |||
| b10dae7655 | |||
| c799b9efd6 | |||
| fe4834e157 | |||
| 5d972249a4 | |||
| f607a01577 | |||
| 1e232e6a9e | |||
| 431c388129 | |||
| 72b63dac21 | |||
| be82cb9626 | |||
| c2c6f711b5 | |||
| 3fba81a7d2 | |||
| 6f2b51197f | |||
| 00c801e9dc | |||
| 47e7d05cb9 | |||
| 4ef6d1b149 | |||
| badd19d27c |
@@ -699,23 +699,47 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
const currentTags = prev?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
|
||||
await publishEvent({
|
||||
kind: 10003,
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
#### The `prev` Property on Event Templates
|
||||
|
||||
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
|
||||
|
||||
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
|
||||
- **Update (`prev` provided)**: `published_at` is preserved from the old event
|
||||
- **Old event lacks `published_at`**: nothing is fabricated
|
||||
- **Caller already set `published_at` in tags**: left alone
|
||||
|
||||
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
|
||||
|
||||
```typescript
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
// ...
|
||||
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
|
||||
```
|
||||
|
||||
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
|
||||
|
||||
### D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Manage your interest tabs (hashtags and locations) from the settings page
|
||||
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
|
||||
- Follow packs and follow sets now show author info and action headers in the feed
|
||||
- Posts now show whether they were created or updated, so you can tell when something's been edited
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
|
||||
- Nsite previews now use the same secure sandbox as webxdc apps
|
||||
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
|
||||
|
||||
### Fixed
|
||||
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
|
||||
- Mobile compose box no longer randomly collapses or becomes unclickable
|
||||
- Profile avatar and banner lightbox no longer hides behind the right sidebar
|
||||
- Infinite scroll on custom profile tab feeds no longer reloads the same content
|
||||
- Reaction emoji are now visible on each row in the interactions modal
|
||||
- Missing bottom border on collapsed thread expand button restored
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- 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
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.5.1"
|
||||
versionName "2.6.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.5.1;
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
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.5.1;
|
||||
MARKETING_VERSION = 2.6.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.1] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Manage your interest tabs (hashtags and locations) from the settings page
|
||||
- Edit button on custom profile tabs so you can tweak them without recreating from scratch
|
||||
- Follow packs and follow sets now show author info and action headers in the feed
|
||||
- Posts now show whether they were created or updated, so you can tell when something's been edited
|
||||
|
||||
### Changed
|
||||
- Webxdc games and apps run in a more secure sandbox with stricter content policies and private subdomains
|
||||
- Nsite previews now use the same secure sandbox as webxdc apps
|
||||
- Blobbi items work as instant abilities instead of consumable inventory -- no more fiddly quantity pickers
|
||||
|
||||
### Fixed
|
||||
- Desktop tab bar no longer overflows when you have lots of tabs -- scroll arrows appear automatically
|
||||
- Mobile compose box no longer randomly collapses or becomes unclickable
|
||||
- Profile avatar and banner lightbox no longer hides behind the right sidebar
|
||||
- Infinite scroll on custom profile tab feeds no longer reloads the same content
|
||||
- Reaction emoji are now visible on each row in the interactions modal
|
||||
- Missing bottom border on collapsed thread expand button restored
|
||||
|
||||
## [2.6.0] - 2026-04-05
|
||||
|
||||
### Added
|
||||
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
|
||||
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
|
||||
|
||||
### Changed
|
||||
- Footer links redesigned as compact icon chips for a cleaner look
|
||||
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
|
||||
|
||||
### Fixed
|
||||
- Custom themes now apply correctly when logging in on a new device
|
||||
- Settings and preferences sync reliably across devices
|
||||
- Mobile sidebar links no longer clip into the safe area
|
||||
- Blobbi page background overlay now appears properly on custom themes
|
||||
- Blobbi companion state no longer resets unexpectedly from stale cache data
|
||||
- Letter compose picker no longer gets hidden behind the top navigation arc
|
||||
|
||||
## [2.5.2] - 2026-04-04
|
||||
|
||||
### Added
|
||||
- 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
|
||||
|
||||
+5
-4
@@ -51,6 +51,7 @@ const hardcodedConfig: AppConfig = {
|
||||
appName: "Ditto",
|
||||
appId: "ditto",
|
||||
homePage: "feed",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
autoShareTheme: true,
|
||||
@@ -123,11 +124,11 @@ const hardcodedConfig: AppConfig = {
|
||||
"feed",
|
||||
"notifications",
|
||||
"search",
|
||||
"themes",
|
||||
"letters",
|
||||
"badges",
|
||||
"blobbi",
|
||||
"theme",
|
||||
"badges",
|
||||
"emojis",
|
||||
"letters",
|
||||
"themes",
|
||||
"settings",
|
||||
"help",
|
||||
],
|
||||
|
||||
@@ -77,6 +77,7 @@ const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -151,6 +152,9 @@ export function AppRouter() {
|
||||
</Suspense>
|
||||
</BlobbiActionsProvider>
|
||||
<Routes>
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
|
||||
{/* All routes share the persistent MainLayout (sidebar + nav) */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -37,8 +34,8 @@ interface BlobbiActionInventoryModalProps {
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user confirms using item(s). Now accepts quantity. */
|
||||
onUseItem: (itemId: string, quantity: number) => void;
|
||||
/** Called when user taps Use on an item. Always uses once. */
|
||||
onUseItem: (itemId: string) => void;
|
||||
onOpenShop: () => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
@@ -49,24 +46,19 @@ export function BlobbiActionInventoryModal({
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
profile,
|
||||
profile: _profile,
|
||||
onUseItem,
|
||||
onOpenShop,
|
||||
onOpenShop: _onOpenShop,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
}: BlobbiActionInventoryModalProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
|
||||
// State for confirmation dialog
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// Filter inventory by action type, respecting egg-compatible effects
|
||||
// Get all available items for this action from the catalog (not inventory).
|
||||
// Items are abilities/tools — no ownership required.
|
||||
const availableItems = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
|
||||
}, [profile, action, companion.stage]);
|
||||
return filterInventoryByAction([], action, { stage: companion.stage });
|
||||
}, [action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
const canUse = canUseAction(companion, action);
|
||||
@@ -74,46 +66,9 @@ export function BlobbiActionInventoryModal({
|
||||
|
||||
const isEmpty = availableItems.length === 0;
|
||||
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
setQuantity(1);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
// Reset after starting use
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleCloseConfirmDialog = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShop = () => {
|
||||
onOpenChange(false);
|
||||
onOpenShop();
|
||||
};
|
||||
|
||||
// Quantity controls
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else {
|
||||
setQuantity(Math.min(value, maxQuantity));
|
||||
}
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -161,14 +116,10 @@ export function BlobbiActionInventoryModal({
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">{actionMeta.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-4">
|
||||
You don't have any items for this action. Visit the shop to get some!
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for this action at your Blobbi's current stage.
|
||||
</p>
|
||||
<Button onClick={handleOpenShop} className="gap-2">
|
||||
<ShoppingBag className="size-4" />
|
||||
Open Shop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -181,7 +132,7 @@ export function BlobbiActionInventoryModal({
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleSelectItem(item)}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
disabled={isUsingItem}
|
||||
/>
|
||||
@@ -190,24 +141,6 @@ export function BlobbiActionInventoryModal({
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Confirmation Dialog with Quantity Selector */}
|
||||
{selectedItem && (
|
||||
<BlobbiUseItemConfirmDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={handleCloseConfirmDialog}
|
||||
item={selectedItem}
|
||||
companion={companion}
|
||||
action={action}
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onQuantityChange={handleQuantityInput}
|
||||
onConfirm={handleConfirmUse}
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -238,15 +171,12 @@ function BlobbiInventoryUseRow({
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
// For eggs using medicine, show health preview
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
|
||||
};
|
||||
}
|
||||
if (isEgg && isClean) {
|
||||
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewCleanForEgg(
|
||||
@@ -255,7 +185,6 @@ function BlobbiInventoryUseRow({
|
||||
),
|
||||
};
|
||||
}
|
||||
// Normal stats preview
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
@@ -280,16 +209,12 @@ function BlobbiInventoryUseRow({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
x{item.quantity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown inline on desktop */}
|
||||
<div className="hidden sm:block">
|
||||
{hasChanges && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -308,7 +233,6 @@ function BlobbiInventoryUseRow({
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -350,7 +274,6 @@ function BlobbiInventoryUseRow({
|
||||
{/* Effect Preview - shown below on mobile */}
|
||||
{hasChanges && (
|
||||
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -369,7 +292,6 @@ function BlobbiInventoryUseRow({
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -393,222 +315,3 @@ function BlobbiInventoryUseRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
|
||||
|
||||
interface BlobbiUseItemConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onConfirm: () => void;
|
||||
isUsing: boolean;
|
||||
}
|
||||
|
||||
function BlobbiUseItemConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: BlobbiUseItemConfirmDialogProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Preview stat changes for the selected quantity
|
||||
const statPreview = useMemo(() => {
|
||||
if (!item.effect) return { normalChanges: [], eggChanges: [] };
|
||||
|
||||
if (isEgg && isMedicine) {
|
||||
// Calculate health change for N items
|
||||
const healthDelta = item.effect.health ?? 0;
|
||||
let currentHealth = companion.stats.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
|
||||
}
|
||||
const totalDelta = currentHealth - (companion.stats.health ?? 0);
|
||||
return {
|
||||
normalChanges: [],
|
||||
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isEgg && isClean) {
|
||||
// Calculate hygiene and happiness changes for N items
|
||||
const hygieneDelta = item.effect.hygiene ?? 0;
|
||||
const happinessDelta = item.effect.happiness ?? 0;
|
||||
let currentHygiene = companion.stats.hygiene ?? 0;
|
||||
let currentHappiness = companion.stats.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
|
||||
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
|
||||
}
|
||||
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
|
||||
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
|
||||
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
|
||||
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
|
||||
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
|
||||
return { normalChanges: [], eggChanges: changes };
|
||||
}
|
||||
|
||||
// Normal stats preview - simulate N applications
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
if (delta !== undefined) {
|
||||
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
const changes: Array<{ stat: string; delta: number }> = [];
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
if (delta !== 0) {
|
||||
changes.push({ stat, delta });
|
||||
}
|
||||
}
|
||||
return { normalChanges: changes, eggChanges: [] };
|
||||
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionMeta.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Item Preview */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onDecrease}
|
||||
disabled={quantity <= 1 || isUsing}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
value={quantity}
|
||||
onChange={onQuantityChange}
|
||||
disabled={isUsing}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onIncrease}
|
||||
disabled={quantity >= maxQuantity || isUsing}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{hasChanges && (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statPreview.normalChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
{statPreview.eggChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUsing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isUsing}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isUsing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Using...
|
||||
</>
|
||||
) : (
|
||||
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,13 +98,6 @@ export function InlineSingCard({
|
||||
cleanup: cleanupPlayback,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup all resources
|
||||
const cleanupAll = useCallback(() => {
|
||||
// Stop timer
|
||||
@@ -138,6 +131,13 @@ export function InlineSingCard({
|
||||
}
|
||||
}, [audioUrl, cleanupPlayback]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, [cleanupAll]);
|
||||
|
||||
// Reset recording
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanupAll();
|
||||
|
||||
@@ -82,22 +82,6 @@ export function SingModal({
|
||||
// Track the actual MIME type used by the recorder
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
@@ -142,6 +126,22 @@ export function SingModal({
|
||||
// Keep lyrics when re-recording so user can sing the same song
|
||||
}, [cleanup]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open, cleanup, resetRecording]);
|
||||
|
||||
// Handle getting random lyrics
|
||||
const handleRandomLyrics = useCallback(() => {
|
||||
const lyrics = getRandomLyrics();
|
||||
|
||||
@@ -168,7 +168,7 @@ export function useActiveTaskProcess(
|
||||
}, [processType, hatchTasks, evolveTasks]);
|
||||
|
||||
// Extract tasks and state from active result
|
||||
const tasks = activeResult?.tasks ?? [];
|
||||
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
|
||||
const isLoading = activeResult?.isLoading ?? false;
|
||||
const allCompleted = activeResult?.allCompleted ?? false;
|
||||
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -24,7 +25,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
updateBlobbiTags,
|
||||
isValidBlobbiEvent,
|
||||
parseBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
|
||||
|
||||
@@ -34,8 +40,6 @@ export interface UseBlobbiCareActivityParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
export interface CareActivityResult {
|
||||
@@ -59,8 +63,8 @@ export interface CareActivityResult {
|
||||
export function useBlobbiCareActivity({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiCareActivityParams) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -78,12 +82,24 @@ export function useBlobbiCareActivity({
|
||||
throw new Error('No companion available');
|
||||
}
|
||||
|
||||
// Fetch fresh companion from relays (read-modify-write pattern)
|
||||
const freshEvents = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': [companion.d],
|
||||
}]);
|
||||
const freshCompanion = freshEvents
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map(e => parseBlobbiEvent(e))
|
||||
.find(Boolean) ?? companion;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Calculate what the streak update should be
|
||||
// Calculate what the streak update should be using fresh data
|
||||
const result = calculateStreakUpdate(
|
||||
companion.careStreak,
|
||||
companion.careStreakLastDay,
|
||||
freshCompanion.careStreak,
|
||||
freshCompanion.careStreakLastDay,
|
||||
now
|
||||
);
|
||||
|
||||
@@ -96,29 +112,29 @@ export function useBlobbiCareActivity({
|
||||
};
|
||||
}
|
||||
|
||||
// Get the tag updates
|
||||
const streakUpdates = getStreakTagUpdates(companion, now);
|
||||
// Get the tag updates using fresh data
|
||||
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
|
||||
|
||||
if (!streakUpdates) {
|
||||
// Shouldn't happen if wasUpdated is true, but handle gracefully
|
||||
return {
|
||||
wasUpdated: false,
|
||||
newStreak: companion.careStreak ?? 0,
|
||||
newStreak: freshCompanion.careStreak ?? 0,
|
||||
action: 'same_day',
|
||||
};
|
||||
}
|
||||
|
||||
// Build updated tags
|
||||
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
|
||||
// Build updated tags from fresh data
|
||||
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
|
||||
|
||||
// Publish the updated event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: companion.event.content,
|
||||
content: freshCompanion.event.content,
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update local cache
|
||||
// Update local cache (optimistic — no invalidation needed)
|
||||
updateCompanionEvent(event);
|
||||
|
||||
// Update session tracker
|
||||
@@ -128,9 +144,9 @@ export function useBlobbiCareActivity({
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[CareActivity] Streak updated:', {
|
||||
action: result.action,
|
||||
previousStreak: companion.careStreak,
|
||||
previousStreak: freshCompanion.careStreak,
|
||||
newStreak: result.newStreak,
|
||||
lastDay: companion.careStreakLastDay,
|
||||
lastDay: freshCompanion.careStreakLastDay,
|
||||
newDay: result.newLastDay,
|
||||
});
|
||||
}
|
||||
@@ -141,11 +157,6 @@ export function useBlobbiCareActivity({
|
||||
action: result.action,
|
||||
};
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.wasUpdated) {
|
||||
invalidateCompanion();
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('[CareActivity] Failed to update streak:', error);
|
||||
},
|
||||
|
||||
@@ -69,15 +69,11 @@ export interface UseBlobbiDirectActionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration happened) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to execute a direct action on a Blobbi companion.
|
||||
* Direct actions (play_music, sing) don't consume inventory items.
|
||||
* Direct actions (play_music, sing) don't require selecting an item.
|
||||
* They directly affect happiness stat.
|
||||
*
|
||||
* This hook:
|
||||
@@ -92,8 +88,6 @@ export function useBlobbiDirectAction({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiDirectActionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -189,12 +183,6 @@ export function useBlobbiDirectAction({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
happinessChange: happinessDelta,
|
||||
|
||||
@@ -66,10 +66,6 @@ export interface UseStartIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +108,6 @@ export function useStartIncubation({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
@@ -269,12 +263,6 @@ export function useStartIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -329,10 +317,6 @@ export interface UseStopIncubationParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,8 +347,6 @@ export function useStopIncubation({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopIncubationParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -435,12 +417,6 @@ export function useStopIncubation({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -480,10 +456,6 @@ export interface UseStartEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,8 +483,6 @@ export function useStartEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStartEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -585,12 +555,6 @@ export function useStartEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -631,10 +595,6 @@ export interface UseStopEvolutionParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -665,8 +625,6 @@ export function useStopEvolution({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseStopEvolutionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -736,12 +694,6 @@ export function useStopEvolution({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
name: canonical.companion.name,
|
||||
@@ -784,10 +736,6 @@ export interface UseSyncTaskCompletionsParams {
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -827,8 +775,6 @@ export function useSyncTaskCompletions({
|
||||
companion,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseSyncTaskCompletionsParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -923,11 +869,6 @@ export function useSyncTaskCompletions({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
if (DEBUG_TASK_SYNC) {
|
||||
console.log('[TaskSync] Published successfully:', tagsToAdd);
|
||||
|
||||
@@ -69,10 +69,6 @@ export interface UseBlobbiStageTransitionParams {
|
||||
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries (needed if migration occurred) */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +109,6 @@ export function useBlobbiHatch({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -220,12 +214,6 @@ export function useBlobbiHatch({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'egg',
|
||||
@@ -268,8 +256,6 @@ export function useBlobbiEvolve({
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
}: UseBlobbiStageTransitionParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
@@ -376,12 +362,6 @@ export function useBlobbiEvolve({
|
||||
});
|
||||
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate profile if migration occurred
|
||||
if (canonical.wasMigrated) {
|
||||
invalidateProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
previousStage: 'baby',
|
||||
|
||||
@@ -6,19 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
clampStat,
|
||||
@@ -37,23 +33,19 @@ import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
/**
|
||||
* Request payload for using an inventory item
|
||||
* Request payload for using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemRequest {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
/** Number of items to use (defaults to 1) */
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of using an inventory item
|
||||
* Result of using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
itemName: string;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
|
||||
statsChanged: Record<string, number>;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
@@ -71,50 +63,44 @@ export interface UseBlobbiUseInventoryItemParams {
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration (use instead of profile.allTags) */
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration (use instead of profile.storage) */
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Update profile event in local cache */
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
/** Invalidate profile queries */
|
||||
invalidateProfile: () => void;
|
||||
}
|
||||
|
||||
// Import NostrEvent type
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to use an inventory item on a Blobbi companion.
|
||||
* Hook to use an item on a Blobbi companion.
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — no
|
||||
* inventory ownership or quantity is required.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion stage (eggs can't use items)
|
||||
* 2. Validates the item exists in storage
|
||||
* 3. Ensures canonical format before action
|
||||
* 4. Applies item effects to Blobbi stats
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
* 6. Decrements item from profile storage (kind 11125)
|
||||
* 7. Invalidates relevant queries
|
||||
* 1. Validates the companion and item compatibility
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay, then item effects to Blobbi stats
|
||||
* 4. Updates Blobbi state (kind 31124)
|
||||
*/
|
||||
export function useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
updateProfileEvent: _updateProfileEvent,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
|
||||
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
@@ -128,11 +114,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Validate quantity
|
||||
if (quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
@@ -145,15 +126,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Item not found in catalog');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -216,78 +188,25 @@ export function useBlobbiUseInventoryItem({
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
|
||||
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
|
||||
// won't give more than 100 health total.
|
||||
//
|
||||
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
|
||||
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
|
||||
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
|
||||
// - clean: count when hygiene or happiness INCREASES
|
||||
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
|
||||
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
|
||||
//
|
||||
// Use canonical companion stage for egg checks
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
const isEggCompanion = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
let effectiveItemCount = 0; // Number of items that produced intended effects
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
// Egg medicine handling:
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
// Medicine with health effect directly affects the egg's health stat
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
// Apply health effect N times in sequence with clamping at each step
|
||||
// Only count items that actually INCREASED health (positive effect only)
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHealth = currentHealth;
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
// Only count as effective if health increased (not just changed)
|
||||
if (healthDelta > 0 && currentHealth > prevHealth) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
// Track total actual change (may be less than healthDelta * quantity due to clamping)
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
|
||||
// Apply decayed values for other egg stats
|
||||
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
|
||||
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
// Egg clean/hygiene handling:
|
||||
// Hygiene items affect the egg's hygiene stat
|
||||
// Some hygiene items also give happiness (e.g., bubble bath)
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const hygieneDelta = shopItem.effect.hygiene ?? 0;
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
// Apply effects N times in sequence
|
||||
// Only count items that INCREASED hygiene or happiness (positive effects only)
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHygiene = currentHygiene;
|
||||
const prevHappiness = currentHappiness;
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
// Count as effective if hygiene OR happiness increased (positive effects only)
|
||||
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
|
||||
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
|
||||
if (hygieneIncreased || happinessIncreased) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
@@ -298,58 +217,12 @@ export function useBlobbiUseInventoryItem({
|
||||
statsChanged.happiness = totalHappinessChange;
|
||||
}
|
||||
|
||||
// Apply decayed health
|
||||
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
// Apply item effects N times in sequence ON TOP of decayed stats
|
||||
// Use action-aware effectiveness checking for XP calculation
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
const effect = shopItem.effect;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevStats = { ...currentStats };
|
||||
currentStats = applyItemEffects(currentStats, effect);
|
||||
|
||||
// Action-aware effectiveness check:
|
||||
// Only count INTENDED positive effects, not negative side effects
|
||||
let isEffective = false;
|
||||
|
||||
if (action === 'feed') {
|
||||
// Feed: count when hunger/energy/health/happiness INCREASE
|
||||
// Do NOT count hygiene decrease (that's a side effect)
|
||||
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
|
||||
} else if (action === 'clean') {
|
||||
// Clean: count when hygiene or happiness INCREASES
|
||||
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hygieneIncreased || happinessIncreased;
|
||||
} else if (action === 'medicine') {
|
||||
// Medicine: count when health/energy/happiness INCREASE
|
||||
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = healthIncreased || energyIncreased || happinessIncreased;
|
||||
} else if (action === 'play') {
|
||||
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
|
||||
// Playing naturally consumes energy, so energy decrease counts as valid
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
|
||||
isEffective = happinessIncreased || energyDecreased;
|
||||
}
|
||||
|
||||
if (isEffective) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
@@ -382,11 +255,8 @@ export function useBlobbiUseInventoryItem({
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (Based on effective item count) ───
|
||||
// Only grant XP for items that actually changed stats.
|
||||
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
|
||||
// This prevents XP farming by mass-using items after stats are already maxed.
|
||||
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
|
||||
// ─── Apply XP Gain ───
|
||||
const xpGained = calculateInventoryActionXP(action, 1);
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
@@ -406,46 +276,25 @@ export function useBlobbiUseInventoryItem({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
|
||||
// instead of profile.storage/profile.allTags to avoid restoring
|
||||
// stale/legacy values after migration
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
invalidateCompanion();
|
||||
invalidateProfile();
|
||||
// Items are free to use — no storage decrement needed.
|
||||
// No query invalidation needed — the optimistic update above keeps the
|
||||
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
|
||||
// before every mutation (read-modify-write pattern).
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
quantity,
|
||||
effectiveItemCount, // How many items actually changed stats
|
||||
statsChanged,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, quantity, xpGained }) => {
|
||||
onSuccess: ({ itemName, action, xpGained }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
|
||||
description: `Used ${itemName} on your Blobbi. ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
|
||||
@@ -55,9 +55,6 @@ export {
|
||||
getInteractionCount,
|
||||
filterPersistentTasks,
|
||||
sanitizeToHashtag,
|
||||
isValidHatchPost,
|
||||
isValidBlobbiPost, // Legacy export
|
||||
buildHatchPhrase,
|
||||
KIND_THEME_DEFINITION,
|
||||
KIND_COLOR_MOMENT,
|
||||
HATCH_REQUIRED_INTERACTIONS,
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
// ─── Action Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Actions that consume inventory items
|
||||
* Item-based care actions (use a shop catalog item on the companion)
|
||||
*/
|
||||
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
|
||||
|
||||
/**
|
||||
* Non-inventory actions that don't consume items
|
||||
* These actions affect stats directly without using shop items.
|
||||
* Direct actions that don't use items.
|
||||
* These actions affect stats directly without selecting a shop item.
|
||||
*/
|
||||
export type DirectAction = 'play_music' | 'sing';
|
||||
|
||||
/**
|
||||
* All Blobbi actions (inventory + direct)
|
||||
* All Blobbi actions (item-based + direct)
|
||||
*/
|
||||
export type BlobbiAction = InventoryAction | DirectAction;
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for UI display (inventory actions)
|
||||
* Action metadata for UI display (item-based care actions)
|
||||
*/
|
||||
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
|
||||
feed: {
|
||||
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for direct actions (non-inventory)
|
||||
* Action metadata for direct actions (no item required)
|
||||
*/
|
||||
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
|
||||
play_music: {
|
||||
@@ -270,10 +270,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
|
||||
return effects.happiness !== undefined && effects.happiness !== 0;
|
||||
}
|
||||
|
||||
// ─── Inventory Helpers ────────────────────────────────────────────────────────
|
||||
// ─── Item Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved inventory item with shop metadata
|
||||
* Resolved catalog item with shop metadata
|
||||
*/
|
||||
export interface ResolvedInventoryItem {
|
||||
itemId: string;
|
||||
@@ -285,7 +285,7 @@ export interface ResolvedInventoryItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering inventory by action
|
||||
* Options for filtering catalog items by action
|
||||
*/
|
||||
export interface FilterInventoryOptions {
|
||||
/** Companion stage - used to filter items by egg-compatible effects */
|
||||
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inventory items by action type.
|
||||
* Returns resolved items with shop metadata.
|
||||
* Get all available items for an action type from the shop catalog.
|
||||
* Items are abilities/tools — no inventory ownership is required.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
storage: StorageItem[],
|
||||
_storage: StorageItem[],
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
const allowedType = ACTION_TO_ITEM_TYPE[action];
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
const isEgg = options.stage === 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
for (const storageItem of storage) {
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== allowedType) continue;
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
// Shell Repair Kit: only show for eggs in medicine modal
|
||||
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: shopItem.id,
|
||||
quantity: Infinity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
@@ -376,7 +374,7 @@ export function decrementStorageItem(
|
||||
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stages that can use general inventory items (food, toys, hygiene)
|
||||
* Stages that can use general items (food, toys, hygiene)
|
||||
*/
|
||||
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
|
||||
|
||||
@@ -409,14 +407,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
|
||||
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific inventory action.
|
||||
* Check if a companion can use a specific item action.
|
||||
*
|
||||
* Note: This function no longer hard-blocks egg actions at the domain layer.
|
||||
* UI visibility is handled separately by `isActionVisibleForStage()`.
|
||||
* The domain layer allows all actions - UI chooses what to show.
|
||||
*/
|
||||
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
|
||||
// All stages can technically use all inventory actions at the domain layer.
|
||||
// All stages can technically use all item actions at the domain layer.
|
||||
// UI filtering determines what actions are shown to users.
|
||||
return true;
|
||||
}
|
||||
@@ -442,7 +440,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use general inventory items (feed, play, clean).
|
||||
* Check if a companion can use general items (feed, play, clean).
|
||||
* Eggs cannot use food, toys, or hygiene items.
|
||||
* @deprecated Use canUseAction(companion, action) for action-specific checks
|
||||
*/
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* Design Philosophy:
|
||||
* - Different actions award different XP to reflect their complexity/value
|
||||
* - XP values are balanced to encourage variety in care activities
|
||||
* - Direct actions (sing, play_music) give moderate XP as they're free
|
||||
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
|
||||
* - Item actions (feed, play, clean, medicine) give varied XP per action type
|
||||
* - Direct actions (sing, play_music) give moderate XP
|
||||
* - XP accumulates across all life stages and never resets
|
||||
*/
|
||||
|
||||
@@ -17,19 +17,18 @@ import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-actio
|
||||
// ─── XP Values by Action ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base XP values for inventory actions (feed, play, clean, medicine).
|
||||
* These actions consume items from the player's storage.
|
||||
* Base XP values for item-based care actions (feed, play, clean, medicine).
|
||||
*/
|
||||
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
|
||||
feed: 5, // Feeding is common and essential - moderate XP
|
||||
play: 8, // Playing toys provides good interaction - higher XP
|
||||
clean: 6, // Hygiene maintenance is important - moderate-high XP
|
||||
medicine: 10, // Medicine is costly and critical - highest inventory XP
|
||||
medicine: 10, // Medicine is critical - highest item XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Base XP values for direct actions (play_music, sing).
|
||||
* These actions don't consume items - they're free activities.
|
||||
* These actions don't require selecting an item.
|
||||
*/
|
||||
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
|
||||
play_music: 7, // Playing music is engaging - good XP
|
||||
@@ -58,11 +57,10 @@ export function calculateActionXP(action: BlobbiAction): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total XP gain for using multiple items.
|
||||
* Each item use counts as a separate action for XP purposes.
|
||||
* Calculate XP gain for an item-based care action.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of items used (defaults to 1)
|
||||
* @param quantity - Number of times performed (always 1 in current usage)
|
||||
* @returns Total XP points earned
|
||||
*/
|
||||
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
|
||||
@@ -88,8 +86,8 @@ export function applyXPGain(currentXP: number | undefined, xpGain: number): numb
|
||||
* Get XP gain summary for displaying to the user.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of times the action was performed (for inventory actions)
|
||||
* @returns Object with xpGained and total quantity
|
||||
* @param quantity - Number of times the action was performed (always 1 in current usage)
|
||||
* @returns Object with xpGained and quantity
|
||||
*/
|
||||
export function getXPGainSummary(
|
||||
action: BlobbiAction,
|
||||
|
||||
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await contextUseItem(item.id, action, 1);
|
||||
const result = await contextUseItem(item.id, action);
|
||||
|
||||
if (result.success) {
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
@@ -17,12 +17,14 @@ import { useMemo, memo, type RefObject } from 'react';
|
||||
|
||||
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
import type { Blobbi } from '@/blobbi/core/types/blobbi';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
|
||||
|
||||
@@ -248,7 +250,14 @@ export function BlobbiCompanionVisual({
|
||||
)}
|
||||
style={{ transformOrigin: 'center bottom' }}
|
||||
>
|
||||
{(companion.stage === 'baby' || companion.stage === 'adult') && (
|
||||
{companion.stage === 'egg' ? (
|
||||
<BlobbiStageVisual
|
||||
companion={companion as unknown as BlobbiCompanion}
|
||||
size="sm"
|
||||
animated={false}
|
||||
className="size-full"
|
||||
/>
|
||||
) : (
|
||||
<MemoizedBlobbiVisual
|
||||
stage={companion.stage}
|
||||
blobbi={blobbi}
|
||||
|
||||
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
|
||||
}
|
||||
|
||||
/**
|
||||
* End dragging - let gravity take over.
|
||||
* End dragging - hold position where dropped.
|
||||
*/
|
||||
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
|
||||
return {
|
||||
...motion,
|
||||
isDragging: false,
|
||||
// If already at or below ground, snap to ground
|
||||
isGrounded: motion.position.y >= groundY,
|
||||
// Always treat as grounded so companion holds position where dropped
|
||||
isGrounded: true,
|
||||
position: {
|
||||
...motion.position,
|
||||
y: motion.position.y >= groundY ? groundY : motion.position.y,
|
||||
// Clamp to ground if below it
|
||||
y: Math.min(motion.position.y, groundY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
// Track if first entry has completed (for position initialization)
|
||||
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
|
||||
|
||||
// Track viewport size
|
||||
// Track viewport size — listen to both window resize and visualViewport
|
||||
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setViewport({
|
||||
@@ -114,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.visualViewport?.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate bounds and positions
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export function useBlobbiCompanionState({
|
||||
setState('walking');
|
||||
setDirection('right');
|
||||
setTargetX(targetX);
|
||||
}, [bounds.maxX]);
|
||||
}, [bounds.maxX, motionRef]);
|
||||
|
||||
/**
|
||||
* Generate a random observation target on screen.
|
||||
@@ -136,7 +136,7 @@ export function useBlobbiCompanionState({
|
||||
setState('walking');
|
||||
setDirection(newDirection);
|
||||
setTargetX(targetXPos);
|
||||
}, [bounds, generateObservationTarget]);
|
||||
}, [bounds, generateObservationTarget, motionRef]);
|
||||
|
||||
// Make a decision about what to do next
|
||||
const makeDecision = useCallback(() => {
|
||||
@@ -176,7 +176,7 @@ export function useBlobbiCompanionState({
|
||||
// Schedule next decision
|
||||
const duration = transition.duration ?? randomDuration(config.idleTime);
|
||||
timerRef.current = window.setTimeout(makeDecision, duration);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation]);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation, motionRef]);
|
||||
|
||||
// Handle reaching target
|
||||
const onReachedTarget = useCallback(() => {
|
||||
@@ -255,7 +255,7 @@ export function useBlobbiCompanionState({
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
|
||||
|
||||
// Pause decisions while dragging
|
||||
// We poll isDragging via interval since motionRef changes don't trigger re-renders
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
* idle -> rising -> inspecting -> entering -> complete
|
||||
*
|
||||
* Route change behavior:
|
||||
* - Cancels current entry immediately
|
||||
* - Waits 1 second
|
||||
* - Restarts entry for the new page
|
||||
* - Companion keeps its current position (no re-entry animation)
|
||||
* - Only initial mount and companion changes trigger entry animations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -310,20 +309,11 @@ export function useBlobbiEntryAnimation({
|
||||
// Random entry type for new companion (fall or rise)
|
||||
const entryType: EntryType = Math.random() < 0.5 ? 'fall' : 'rise';
|
||||
startEntry(entryType);
|
||||
} else if (routeChanged && companionId) {
|
||||
// Route changed - determine direction for new route
|
||||
const entryType = getEntryDirection(previousPath, pathname, sidebarOrder);
|
||||
|
||||
// Immediately hide Blobbi and cancel current entry
|
||||
cancelEntry();
|
||||
setIsHiddenForTransition(true);
|
||||
|
||||
// Wait 1 second, then start the new entry animation
|
||||
routeChangeTimeoutRef.current = setTimeout(() => {
|
||||
startEntry(entryType);
|
||||
}, entryConfig.routeChangeRestartDelay);
|
||||
} else if (routeChanged) {
|
||||
// Route changed - companion keeps its position, no re-entry animation.
|
||||
// Just update the ref so future changes compare against the new path.
|
||||
}
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
|
||||
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
|
||||
|
||||
/**
|
||||
* Animation loop for FALL entry.
|
||||
|
||||
+7
-9
@@ -15,17 +15,15 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
|
||||
import { useBlobbiItemUse } from './useBlobbiItemUse';
|
||||
import {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type BlobbiActionsContextValue,
|
||||
type BlobbiActionsContextInternal,
|
||||
} from './BlobbiActionsProvider';
|
||||
} from './BlobbiActionsContextDef';
|
||||
|
||||
// Re-export everything from the provider module for backward compatibility
|
||||
// Re-export types and context from the def module for backward compatibility
|
||||
export {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type BlobbiActionsContextValue,
|
||||
@@ -64,13 +62,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
// Create stable useItem function that:
|
||||
// 1. Uses registered function if available (from BlobbiPage)
|
||||
// 2. Falls back to built-in hook if no registration
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
// Try registered function first (from BlobbiPage)
|
||||
if (context?.registerRef.current) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[BlobbiActions] Using registered item-use function');
|
||||
}
|
||||
return context.registerRef.current(itemId, action, quantity);
|
||||
return context.registerRef.current(itemId, action);
|
||||
}
|
||||
|
||||
// Check if fallback can handle it
|
||||
@@ -88,7 +86,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[BlobbiActions] Using fallback item-use hook');
|
||||
}
|
||||
return fallbackItemUse.useItem(itemId, action, quantity);
|
||||
return fallbackItemUse.useItem(itemId, action);
|
||||
}, [context, fallbackItemUse]);
|
||||
|
||||
// Determine canUseItems: true if registered OR fallback can use
|
||||
@@ -136,14 +134,14 @@ export function useBlobbiActionsRegistration(
|
||||
useItemRef.current = useItemFn;
|
||||
|
||||
// Create a stable wrapper that delegates to the ref
|
||||
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
if (!useItemRef.current) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Item use function not available',
|
||||
};
|
||||
}
|
||||
return useItemRef.current(itemId, action, quantity);
|
||||
return useItemRef.current(itemId, action);
|
||||
}, []);
|
||||
|
||||
// Update refs and notify only when canUseItems actually changes
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* BlobbiActionsContextDef
|
||||
*
|
||||
* Lightweight context definition and types for the Blobbi actions system.
|
||||
* Separated from the provider component to avoid react-refresh warnings.
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of using an item via the context.
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
/** Whether the use was successful */
|
||||
success: boolean;
|
||||
/** Stats that changed (key = stat name, value = delta) */
|
||||
statsChanged?: Record<string, number>;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function signature for using an item (always uses once).
|
||||
*/
|
||||
export type UseItemFunction = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
) => Promise<UseItemResult>;
|
||||
|
||||
/**
|
||||
* Context value for Blobbi actions (consumer side).
|
||||
*/
|
||||
export interface BlobbiActionsContextValue {
|
||||
/**
|
||||
* Use an item on the current companion.
|
||||
* Works even without BlobbiPage registration (uses fallback).
|
||||
*/
|
||||
useItem: UseItemFunction;
|
||||
|
||||
/** Whether an item use operation is currently in progress */
|
||||
isUsingItem: boolean;
|
||||
|
||||
/** Whether items can be used (companion exists and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context value (includes registration functions).
|
||||
*/
|
||||
export interface BlobbiActionsContextInternal {
|
||||
/** Register item-use functionality (called by BlobbiPage) */
|
||||
registerRef: React.MutableRefObject<UseItemFunction | null>;
|
||||
/** Whether items can currently be used (via registration) */
|
||||
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Whether an item is currently being used (via registration) */
|
||||
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Force update consumers (called sparingly) */
|
||||
notifyUpdate: () => void;
|
||||
/** Subscribe to updates */
|
||||
subscribe: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
|
||||
@@ -10,75 +10,13 @@
|
||||
* BlobbiPage, both of which are lazy-loaded.
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
|
||||
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of using an item via the context.
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
/** Whether the use was successful */
|
||||
success: boolean;
|
||||
/** Stats that changed (key = stat name, value = delta) */
|
||||
statsChanged?: Record<string, number>;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function signature for using an item.
|
||||
*/
|
||||
export type UseItemFunction = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
quantity?: number
|
||||
) => Promise<UseItemResult>;
|
||||
|
||||
/**
|
||||
* Context value for Blobbi actions (consumer side).
|
||||
*/
|
||||
export interface BlobbiActionsContextValue {
|
||||
/**
|
||||
* Use an inventory item on the current companion.
|
||||
* Works even without BlobbiPage registration (uses fallback).
|
||||
*/
|
||||
useItem: UseItemFunction;
|
||||
|
||||
/** Whether an item use operation is currently in progress */
|
||||
isUsingItem: boolean;
|
||||
|
||||
/** Whether items can be used (companion exists and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context value (includes registration functions).
|
||||
*/
|
||||
export interface BlobbiActionsContextInternal {
|
||||
/** Register item-use functionality (called by BlobbiPage) */
|
||||
registerRef: React.MutableRefObject<UseItemFunction | null>;
|
||||
/** Whether items can currently be used (via registration) */
|
||||
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Whether an item is currently being used (via registration) */
|
||||
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Force update consumers (called sparingly) */
|
||||
notifyUpdate: () => void;
|
||||
/** Subscribe to updates */
|
||||
subscribe: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
|
||||
import {
|
||||
BlobbiActionsContext,
|
||||
type UseItemFunction,
|
||||
type BlobbiActionsContextInternal,
|
||||
} from './BlobbiActionsContextDef';
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
/**
|
||||
* HangingItems
|
||||
*
|
||||
* Displays inventory items as hanging elements from the top of the screen.
|
||||
* Displays available items as hanging elements from the top of the screen.
|
||||
* Each item appears as a circle connected to the top by a thin vertical line,
|
||||
* creating a playful, spatial feel.
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — they are
|
||||
* always available and not consumed on use.
|
||||
*
|
||||
* State Model:
|
||||
* - Container states: hidden → opening → open → closing → hidden
|
||||
* - Hanging items = available inventory that can still be released
|
||||
* - Hanging items = catalog items available for the selected action
|
||||
* - Released/dropped items = instances currently in the world (tracked with unique IDs)
|
||||
* - Multiple instances of the same item type can exist simultaneously on the ground
|
||||
*
|
||||
* Key Design Principle:
|
||||
* The hanging row represents "releasable quantity" - clicking releases ONE instance
|
||||
* and immediately decrements the visible quantity. A new hanging copy remains if
|
||||
* quantity > 1. The released instance tracks separately with a unique instance ID.
|
||||
*
|
||||
* Features:
|
||||
* - Smooth open/close slide animations (items descend/ascend)
|
||||
* - Thin vertical lines from the top of screen
|
||||
* - Circular containers for hanging items
|
||||
* - Click releases item: one instance falls, remaining quantity stays hanging
|
||||
* - Click releases item: one instance falls to the ground
|
||||
* - Multiple dropped instances of same item type can exist
|
||||
* - Contact detection: items auto-use when touching Blobbi
|
||||
* - Click-to-use: click landed items to use them
|
||||
@@ -119,7 +117,7 @@ interface HangingItemsProps {
|
||||
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
|
||||
/**
|
||||
* Callback when an item is collected by Blobbi (contact).
|
||||
* @deprecated Use onItemUse instead for proper item consumption flow.
|
||||
* @deprecated Use onItemUse instead for proper item-use flow.
|
||||
*/
|
||||
onItemCollected?: (item: CompanionItem) => void;
|
||||
/**
|
||||
@@ -156,7 +154,7 @@ const HANGING_CONFIG = {
|
||||
baseFallDistance: 500,
|
||||
/** Ground offset from bottom of viewport */
|
||||
defaultGroundOffset: 40,
|
||||
/** Size of quantity badge */
|
||||
/** Size of badge (unused — kept for config consistency) */
|
||||
badgeSize: 20,
|
||||
/** Size of landed item hitbox for contact detection */
|
||||
landedItemSize: 40,
|
||||
@@ -406,7 +404,7 @@ export function HangingItems({
|
||||
|
||||
// Track how many instances of each item type have been released (not yet used)
|
||||
// Key: item.id (type ID), Value: count of released instances
|
||||
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Counter for generating unique instance IDs
|
||||
const instanceCounterRef = useRef(0);
|
||||
@@ -566,7 +564,7 @@ export function HangingItems({
|
||||
|
||||
// Start the loop
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}, []);
|
||||
}, [calculateFallDuration]);
|
||||
|
||||
// Cleanup animation on unmount
|
||||
useEffect(() => {
|
||||
@@ -670,7 +668,7 @@ export function HangingItems({
|
||||
});
|
||||
// Also remove from zone tracking
|
||||
itemsInZoneRef.current.delete(instanceId);
|
||||
// Decrement the released count for this item type (since the instance is now consumed)
|
||||
// Decrement the released count for this item type (instance removed from screen)
|
||||
setReleasedCountByItemId(prev => {
|
||||
const next = new Map(prev);
|
||||
const currentCount = next.get(item.id) || 0;
|
||||
@@ -985,15 +983,9 @@ export function HangingItems({
|
||||
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
|
||||
};
|
||||
|
||||
// Calculate hanging items with their remaining quantities
|
||||
// An item appears in the hanging row if (quantity - releasedCount) > 0
|
||||
const hangingItems = items
|
||||
.map(item => {
|
||||
const releasedCount = releasedCountByItemId.get(item.id) || 0;
|
||||
const remainingQuantity = item.quantity - releasedCount;
|
||||
return { ...item, quantity: remainingQuantity };
|
||||
})
|
||||
.filter(item => item.quantity > 0);
|
||||
// All items are always visible — they are abilities, not consumable inventory.
|
||||
// No quantity filtering needed.
|
||||
const hangingItems = items;
|
||||
|
||||
// Should we render the hanging container?
|
||||
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
|
||||
@@ -1033,7 +1025,7 @@ export function HangingItems({
|
||||
>
|
||||
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1102,8 +1094,8 @@ export function HangingItems({
|
||||
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
|
||||
}}
|
||||
onClick={() => handleItemClick(item, itemX)}
|
||||
title={`${item.name} (x${item.quantity})`}
|
||||
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
|
||||
title={item.name}
|
||||
aria-label={`${item.name}. Click to release.`}
|
||||
>
|
||||
{/* Item emoji */}
|
||||
<span
|
||||
@@ -1114,24 +1106,6 @@ export function HangingItems({
|
||||
>
|
||||
{item.emoji}
|
||||
</span>
|
||||
|
||||
{/* Quantity badge */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1",
|
||||
"flex items-center justify-center",
|
||||
"bg-primary text-primary-foreground",
|
||||
"text-xs font-semibold rounded-full",
|
||||
"shadow-md"
|
||||
)}
|
||||
style={{
|
||||
minWidth: HANGING_CONFIG.badgeSize,
|
||||
height: HANGING_CONFIG.badgeSize,
|
||||
padding: '0 5px',
|
||||
}}
|
||||
>
|
||||
{item.quantity}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,10 +76,10 @@ export { useBlobbiItemUse } from './useBlobbiItemUse';
|
||||
// Context
|
||||
export {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
useBlobbiActions,
|
||||
useBlobbiActionsRegistration,
|
||||
} from './BlobbiActionsContext';
|
||||
export { BlobbiActionsProvider } from './BlobbiActionsProvider';
|
||||
|
||||
// Components
|
||||
export { CompanionActionMenu } from './CompanionActionMenu';
|
||||
|
||||
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
|
||||
|
||||
/**
|
||||
* Normalized item representation for the companion UI.
|
||||
* This is a simplified view of inventory items optimized for rendering.
|
||||
* This is a simplified view of shop catalog items optimized for rendering.
|
||||
*/
|
||||
export interface CompanionItem {
|
||||
/** Unique item ID (matches shop item ID) */
|
||||
@@ -74,7 +74,7 @@ export interface CompanionItem {
|
||||
emoji: string;
|
||||
/** Item category */
|
||||
category: ShopItemCategory;
|
||||
/** Quantity available in inventory */
|
||||
/** Availability (always Infinity — items are reusable abilities) */
|
||||
quantity: number;
|
||||
/** Item effects when used */
|
||||
effect?: ItemEffect;
|
||||
|
||||
@@ -27,13 +27,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
parseBlobbiEvent,
|
||||
isValidBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseItemForStage,
|
||||
getStageRestrictionMessage,
|
||||
@@ -59,7 +55,7 @@ import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
|
||||
|
||||
import type { UseItemFunction } from './BlobbiActionsProvider';
|
||||
import type { UseItemFunction } from './BlobbiActionsContextDef';
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch profile if not provided
|
||||
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
|
||||
const { profile: fetchedProfile } = useBlobbonautProfile();
|
||||
const profile = options.profile ?? fetchedProfile;
|
||||
|
||||
// Per-item cooldown tracking (ref to avoid re-renders)
|
||||
@@ -232,16 +228,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
});
|
||||
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
|
||||
|
||||
// Core mutation for using items
|
||||
// Core mutation for using items (always uses once)
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
itemId,
|
||||
action,
|
||||
quantity = 1,
|
||||
}: {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
quantity?: number;
|
||||
}): Promise<{ statsChanged: Record<string, number> }> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
@@ -259,11 +253,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
// Validate quantity
|
||||
if (quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
// Check stage restrictions
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
@@ -283,15 +272,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -319,17 +299,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
const isEggCompanion = companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
}
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
@@ -339,15 +315,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
const hygieneDelta = shopItem.effect.hygiene ?? 0;
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
}
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
@@ -362,11 +331,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentStats = applyItemEffects(currentStats, shopItem.effect);
|
||||
}
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
@@ -414,36 +380,19 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
|
||||
updateCompanionInCache(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(profile.allTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
// Items are free to use — no storage decrement needed.
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
|
||||
|
||||
return { statsChanged };
|
||||
},
|
||||
onSuccess: (_, { itemId, action, quantity = 1 }) => {
|
||||
onSuccess: (_, { itemId, action }) => {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
|
||||
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
@@ -468,7 +417,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
});
|
||||
|
||||
// Wrapper function that matches UseItemFunction signature and includes cooldown check
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
// Check cooldown first
|
||||
if (isItemOnCooldown(itemId)) {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -481,7 +430,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mutation.mutateAsync({ itemId, action, quantity });
|
||||
const result = await mutation.mutateAsync({ itemId, action });
|
||||
return {
|
||||
success: true,
|
||||
statsChanged: result.statsChanged,
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
@@ -53,27 +52,30 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<BlobbiCompanion | null> => {
|
||||
const event = await fetchFreshEvent(
|
||||
nostr,
|
||||
{ kinds: [KIND_BLOBBI_STATE], authors: [pubkey], '#d': [dTag] },
|
||||
{ eoseTimeout: 1000 },
|
||||
);
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag],
|
||||
}]);
|
||||
|
||||
if (!event || !isValidBlobbiEvent(event)) return null;
|
||||
return parseBlobbiEvent(event) ?? null;
|
||||
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]);
|
||||
|
||||
/** 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],
|
||||
@@ -87,9 +89,6 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
|
||||
companions: Object.values(newCompanionsByD),
|
||||
});
|
||||
}
|
||||
|
||||
// Also invalidate for background refetch to ensure eventual consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
|
||||
}, [queryClient]);
|
||||
|
||||
const toggleSleep = useCallback(async () => {
|
||||
|
||||
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
import type {
|
||||
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve inventory items for a specific action/category.
|
||||
* Resolve available items for a specific action/category from the shop catalog.
|
||||
*
|
||||
* Items are sourced from the full shop catalog — all items are
|
||||
* available as reusable abilities/tools, filtered only by stage.
|
||||
*
|
||||
* Uses the centralized `canUseItemForStage` function to ensure consistent
|
||||
* stage-based filtering across all UIs:
|
||||
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
|
||||
* filters out all egg-only items from the companion interaction system.
|
||||
*/
|
||||
function resolveItemsForAction(
|
||||
storage: StorageItem[],
|
||||
action: CompanionMenuAction,
|
||||
stage: 'egg' | 'baby' | 'adult'
|
||||
): CompanionItem[] {
|
||||
@@ -89,13 +90,10 @@ function resolveItemsForAction(
|
||||
// Sleep action has no items
|
||||
if (!category) return [];
|
||||
|
||||
const allItems = getLiveShopItems();
|
||||
const items: CompanionItem[] = [];
|
||||
|
||||
for (const storageItem of storage) {
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== category) continue;
|
||||
|
||||
// Use centralized stage-based filtering
|
||||
@@ -104,17 +102,17 @@ function resolveItemsForAction(
|
||||
// - Food/Toys: only for baby/adult (excluded for eggs)
|
||||
// - Medicine: must have health effect
|
||||
// - Hygiene: must have hygiene or happiness effect
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
const usability = canUseItemForStage(shopItem.id, stage);
|
||||
if (!usability.canUse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: storageItem.itemId,
|
||||
id: shopItem.id,
|
||||
name: shopItem.name,
|
||||
emoji: shopItem.icon,
|
||||
category: shopItem.type,
|
||||
quantity: storageItem.quantity,
|
||||
quantity: Infinity,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve items for this action
|
||||
const items = resolveItemsForAction(profile.storage, action, stage);
|
||||
// Resolve items for this action from the catalog (not inventory)
|
||||
const items = resolveItemsForAction(action, stage);
|
||||
|
||||
setMenuState(prev => ({
|
||||
...prev,
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface ItemUseResult {
|
||||
export type UseItemCallback = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
quantity: number
|
||||
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
|
||||
|
||||
/**
|
||||
@@ -67,14 +66,14 @@ export interface UseCompanionItemUseResult {
|
||||
isUsingItem: boolean;
|
||||
/** Get the action type for an item category */
|
||||
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
|
||||
/** Get the inventory action for a menu action */
|
||||
/** Get the care action for a menu action */
|
||||
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map item categories to inventory actions.
|
||||
* Map item categories to care actions.
|
||||
* This is the canonical mapping for how items are used.
|
||||
*/
|
||||
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
|
||||
@@ -85,14 +84,14 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
|
||||
};
|
||||
|
||||
/**
|
||||
* Map menu actions to inventory actions (they match by design).
|
||||
* Map menu actions to item-based care actions (they match by design).
|
||||
*/
|
||||
export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, InventoryAction | null> = {
|
||||
feed: 'feed',
|
||||
play: 'play',
|
||||
medicine: 'medicine',
|
||||
clean: 'clean',
|
||||
sleep: null, // Sleep is a special action, not an inventory action
|
||||
sleep: null, // Sleep is a special action, not item-based
|
||||
};
|
||||
|
||||
// ─── Hook Implementation ──────────────────────────────────────────────────────
|
||||
@@ -108,8 +107,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { useItem, isUsingItem } = useCompanionItemUse({
|
||||
* onUseItem: async (itemId, action, qty) => {
|
||||
* return await executeUseItem({ itemId, action, quantity: qty });
|
||||
* onUseItem: async (itemId, action) => {
|
||||
* return await executeUseItem({ itemId, action });
|
||||
* },
|
||||
* onSuccess: (result) => removeItemFromScreen(result.item),
|
||||
* onFailure: (result) => keepItemOnScreen(result.item),
|
||||
@@ -134,7 +133,7 @@ export function useCompanionItemUse({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the inventory action for a menu action.
|
||||
* Get the care action for a menu action.
|
||||
*/
|
||||
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
|
||||
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
|
||||
@@ -187,7 +186,7 @@ export function useCompanionItemUse({
|
||||
|
||||
try {
|
||||
// Execute the use callback
|
||||
const useResult = await onUseItem(item.id, inventoryAction, 1);
|
||||
const useResult = await onUseItem(item.id, inventoryAction);
|
||||
|
||||
if (useResult.success) {
|
||||
const result: ItemUseResult = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -8,12 +9,18 @@ import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
BLOBBONAUT_PROFILE_KINDS,
|
||||
getBlobbonautQueryDValues,
|
||||
buildMigrationTags,
|
||||
generatePetId10,
|
||||
getCanonicalBlobbiD,
|
||||
isValidBlobbiEvent,
|
||||
isValidBlobbonautEvent,
|
||||
isLegacyBlobbonautKind,
|
||||
migratePetInHas,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbiEvent,
|
||||
parseBlobbonautEvent,
|
||||
parseStorageTags,
|
||||
type BlobbiCompanion,
|
||||
type BlobbonautProfile,
|
||||
@@ -52,10 +59,6 @@ export interface EnsureCanonicalOptions {
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Callback to update localStorage selection if it was pointing to legacy d */
|
||||
updateStoredSelectedD?: (newD: string) => void;
|
||||
/** Callback to invalidate companion query */
|
||||
invalidateCompanion?: () => void;
|
||||
/** Callback to invalidate profile query */
|
||||
invalidateProfile?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +114,7 @@ export interface EnsureCanonicalResult {
|
||||
* ```
|
||||
*/
|
||||
export function useBlobbiMigration() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
@@ -134,8 +138,6 @@ export function useBlobbiMigration() {
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD,
|
||||
invalidateCompanion,
|
||||
invalidateProfile,
|
||||
} = options;
|
||||
|
||||
if (!user?.pubkey) {
|
||||
@@ -190,7 +192,8 @@ export function useBlobbiMigration() {
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
// Update query caches
|
||||
// Update query caches (optimistic — no invalidation needed since we
|
||||
// fetch fresh from relays before every mutation)
|
||||
updateProfileEvent(profileEvent);
|
||||
updateCompanionEvent(canonicalEvent);
|
||||
|
||||
@@ -200,10 +203,6 @@ export function useBlobbiMigration() {
|
||||
updateStoredSelectedD(canonicalD);
|
||||
}
|
||||
|
||||
// Invalidate queries to refetch fresh data
|
||||
invalidateCompanion?.();
|
||||
invalidateProfile?.();
|
||||
|
||||
toast({
|
||||
title: 'Pet upgraded!',
|
||||
description: `${companion.name} has been migrated to the new format.`,
|
||||
@@ -237,29 +236,102 @@ export function useBlobbiMigration() {
|
||||
}
|
||||
}, [user?.pubkey, publishEvent]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest companion event directly from relays, bypassing cache.
|
||||
* This is the read step of the read-modify-write pattern.
|
||||
*/
|
||||
const fetchFreshCompanion = useCallback(async (
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
): Promise<BlobbiCompanion | null> => {
|
||||
const events = await nostr.query([{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag],
|
||||
}]);
|
||||
|
||||
const validEvents = events
|
||||
.filter(isValidBlobbiEvent)
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
if (validEvents.length === 0) return null;
|
||||
return parseBlobbiEvent(validEvents[0]) ?? null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Fetch the freshest profile event directly from relays, bypassing cache.
|
||||
*/
|
||||
const fetchFreshProfile = useCallback(async (
|
||||
pubkey: string,
|
||||
): Promise<BlobbonautProfile | null> => {
|
||||
const dValues = getBlobbonautQueryDValues(pubkey);
|
||||
const events = await nostr.query([{
|
||||
kinds: [...BLOBBONAUT_PROFILE_KINDS],
|
||||
authors: [pubkey],
|
||||
'#d': dValues,
|
||||
}]);
|
||||
|
||||
const validEvents = events.filter(isValidBlobbonautEvent);
|
||||
if (validEvents.length === 0) return null;
|
||||
|
||||
// Prefer current kind over legacy
|
||||
const currentKindEvents = validEvents.filter(e => e.kind === KIND_BLOBBONAUT_PROFILE);
|
||||
if (currentKindEvents.length > 0) {
|
||||
const sorted = currentKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
const legacyKindEvents = validEvents.filter(e => isLegacyBlobbonautKind(e));
|
||||
if (legacyKindEvents.length > 0) {
|
||||
const sorted = legacyKindEvents.sort((a, b) => b.created_at - a.created_at);
|
||||
return parseBlobbonautEvent(sorted[0]) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [nostr]);
|
||||
|
||||
/**
|
||||
* Ensure a Blobbi is in canonical format before performing an action.
|
||||
*
|
||||
* CRITICAL: This fetches fresh data from relays (read-modify-write pattern)
|
||||
* instead of using potentially stale cache data. This prevents state resets
|
||||
* caused by publishing over a newer event with stale cached data.
|
||||
*
|
||||
* If the companion is legacy, it will be migrated first.
|
||||
* Returns the canonical companion to use for the action.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Check if Blobbi is legacy
|
||||
* 2. If legacy: migrate Blobbi
|
||||
* 3. Return the resolved canonical Blobbi
|
||||
* 1. Fetch fresh companion + profile from relays
|
||||
* 2. Check if Blobbi is legacy
|
||||
* 3. If legacy: migrate Blobbi
|
||||
* 4. Return the resolved canonical Blobbi with fresh data
|
||||
*
|
||||
* All interaction handlers should call this before publishing events.
|
||||
*/
|
||||
const ensureCanonicalBlobbiBeforeAction = useCallback(async (
|
||||
options: EnsureCanonicalOptions
|
||||
): Promise<EnsureCanonicalResult | null> => {
|
||||
const { companion, profile } = options;
|
||||
if (!user?.pubkey) return null;
|
||||
|
||||
const { companion: cachedCompanion, profile: cachedProfile } = options;
|
||||
|
||||
// Fetch fresh data from relays (read step of read-modify-write)
|
||||
const [freshCompanion, freshProfile] = await Promise.all([
|
||||
fetchFreshCompanion(user.pubkey, cachedCompanion.d),
|
||||
fetchFreshProfile(user.pubkey),
|
||||
]);
|
||||
|
||||
// Use fresh data, falling back to cached only if relay fetch returned nothing
|
||||
const companion = freshCompanion ?? cachedCompanion;
|
||||
const profile = freshProfile ?? cachedProfile;
|
||||
|
||||
// Check if the companion needs migration
|
||||
if (companion.isLegacy) {
|
||||
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
|
||||
|
||||
const migrationResult = await migrateLegacyBlobbi(options);
|
||||
// Use fresh data in migration options
|
||||
const migrationOptions = { ...options, companion, profile };
|
||||
const migrationResult = await migrateLegacyBlobbi(migrationOptions);
|
||||
|
||||
if (!migrationResult) {
|
||||
// Migration failed, cannot proceed with action
|
||||
@@ -279,7 +351,7 @@ export function useBlobbiMigration() {
|
||||
};
|
||||
}
|
||||
|
||||
// Companion is already canonical, return profile as-is
|
||||
// Companion is already canonical, return fresh data
|
||||
return {
|
||||
wasMigrated: false,
|
||||
companion,
|
||||
@@ -288,7 +360,7 @@ export function useBlobbiMigration() {
|
||||
profileAllTags: profile.allTags,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [migrateLegacyBlobbi]);
|
||||
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
|
||||
|
||||
return {
|
||||
/** Migrate a legacy Blobbi to canonical format */
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { fetchFreshEvents } from '@/lib/fetchFreshEvent';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
isValidBlobbiEvent,
|
||||
@@ -52,34 +51,46 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
// Main query to fetch all companions from relays
|
||||
const query = useQuery({
|
||||
queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags],
|
||||
queryFn: async () => {
|
||||
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)');
|
||||
|
||||
// Fetch all chunks, using a relaxed eoseTimeout (1000ms) so slower
|
||||
// relays have time to respond and we get the freshest events.
|
||||
// Query all chunks in parallel
|
||||
const allEvents: NostrEvent[] = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const events = await fetchFreshEvents(
|
||||
nostr,
|
||||
[{
|
||||
kinds: [KIND_BLOBBI_STATE],
|
||||
authors: [user.pubkey],
|
||||
'#d': chunk,
|
||||
}],
|
||||
{ eoseTimeout: 1000 },
|
||||
);
|
||||
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>();
|
||||
|
||||
@@ -105,6 +116,11 @@ export function useBlobbisCollection(dList: string[] | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useBlobbisCollection] Parsed companions:', {
|
||||
count: companions.length,
|
||||
dTags: Object.keys(companionsByD),
|
||||
});
|
||||
|
||||
return { companionsByD, companions };
|
||||
},
|
||||
enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0,
|
||||
@@ -116,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({
|
||||
@@ -125,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
|
||||
@@ -174,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)
|
||||
|
||||
@@ -288,7 +288,7 @@ export interface BlobbiCompanion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored item in user's profile inventory
|
||||
* Stored item in user's profile (from purchases)
|
||||
*/
|
||||
export interface StorageItem {
|
||||
itemId: string; // Must match a ShopItem.id
|
||||
@@ -316,7 +316,7 @@ export interface BlobbonautProfile {
|
||||
coins: number;
|
||||
/** Petting level (interaction counter) */
|
||||
pettingLevel: number;
|
||||
/** Purchased items inventory */
|
||||
/** Purchased items storage */
|
||||
storage: StorageItem[];
|
||||
/** All tags preserved for republishing */
|
||||
allTags: string[][];
|
||||
@@ -892,15 +892,13 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
|
||||
const isLegacy = isLegacyBlobbiEvent(event);
|
||||
|
||||
// Concise, structured debug log
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Blobbi]', {
|
||||
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
|
||||
name,
|
||||
isLegacy,
|
||||
hasSeed: !!seed,
|
||||
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
|
||||
});
|
||||
}
|
||||
console.log('[Blobbi]', {
|
||||
d: d.length > 30 ? `${d.slice(0, 20)}...` : d,
|
||||
name,
|
||||
isLegacy,
|
||||
hasSeed: !!seed,
|
||||
traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`,
|
||||
});
|
||||
|
||||
// Parse task progress tags: ["task", "name:value"]
|
||||
const tasks: BlobbiTaskProgress[] = [];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun, RefreshCw, SkipForward } from 'lucide-react';
|
||||
import { Egg, Baby, Sparkles, Loader2, RotateCcw, Zap, Heart, Utensils, Droplets, Activity, Battery, Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
@@ -27,18 +27,6 @@ import { ADULT_FORMS } from '@/blobbi/adult-blobbi/types/adult.types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Tour dev actions for the first-hatch tour */
|
||||
interface FirstHatchTourDevActions {
|
||||
/** Skip the post requirement: advance from show_hatch_card to egg_glowing_waiting_click */
|
||||
skipPostRequirement: () => void;
|
||||
/** Reset the entire first-hatch tour so it can be tested again from scratch */
|
||||
resetTour: () => void;
|
||||
/** Current tour step id, or null if not active */
|
||||
currentStepId: string | null;
|
||||
/** Whether the tour has been completed */
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
interface BlobbiDevEditorProps {
|
||||
/** Whether the editor modal is open */
|
||||
isOpen: boolean;
|
||||
@@ -50,8 +38,6 @@ interface BlobbiDevEditorProps {
|
||||
onApply: (updates: BlobbiDevUpdates) => Promise<void>;
|
||||
/** Whether an update is in progress */
|
||||
isUpdating?: boolean;
|
||||
/** Optional: first-hatch tour dev actions (only passed when tour system is available) */
|
||||
tourDevActions?: FirstHatchTourDevActions;
|
||||
}
|
||||
|
||||
/** Updates that can be applied to a Blobbi */
|
||||
@@ -184,7 +170,6 @@ export function BlobbiDevEditor({
|
||||
companion,
|
||||
onApply,
|
||||
isUpdating = false,
|
||||
tourDevActions,
|
||||
}: BlobbiDevEditorProps) {
|
||||
// ─── Local State ───
|
||||
// Initialize from companion values
|
||||
@@ -545,79 +530,7 @@ export function BlobbiDevEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── First-Hatch Tour Controls ─── */}
|
||||
{tourDevActions && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">First-Hatch Tour</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tourDevActions.isCompleted
|
||||
? 'Completed'
|
||||
: tourDevActions.currentStepId
|
||||
? tourDevActions.currentStepId
|
||||
: 'Not started'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Test the first-hatch tour flow without needing to create a real post.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* A. Skip Post Requirement */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.skipPostRequirement();
|
||||
}}
|
||||
disabled={tourDevActions.currentStepId !== 'show_hatch_card'}
|
||||
className="gap-2 text-xs"
|
||||
title="Advance from show_hatch_card to egg_glowing_waiting_click (skips post check)"
|
||||
>
|
||||
<SkipForward className="size-3.5" />
|
||||
Skip Post
|
||||
</Button>
|
||||
|
||||
{/* B. Restart First-Hatch Tour */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
className="gap-2 text-xs"
|
||||
title="Reset the entire first-hatch tour state so it can be tested again"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Restart Tour
|
||||
</Button>
|
||||
|
||||
{/* C. Reset Blobbi to Egg */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStage('egg');
|
||||
setState('active');
|
||||
tourDevActions.resetTour();
|
||||
}}
|
||||
disabled={companion.stage === 'egg'}
|
||||
className="gap-2 text-xs"
|
||||
title="Set stage to egg AND reset the tour — apply changes to test from scratch"
|
||||
>
|
||||
<Egg className="size-3.5" />
|
||||
Reset to Egg + Tour
|
||||
</Button>
|
||||
</div>
|
||||
{companion.stage !== 'egg' && stage === 'egg' && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Stage will change to egg. Click "Apply Changes" to publish, then the tour will auto-start.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Theater } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEmotionDev } from './EmotionDevContext';
|
||||
import { useEmotionDev } from './useEmotionDev';
|
||||
import { isLocalhostDev } from './index';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
|
||||
|
||||
@@ -10,26 +10,10 @@
|
||||
* - Is purely for visual testing/debugging
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { useState, useCallback, type ReactNode } from 'react';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EmotionDevContextValue {
|
||||
/** Current dev emotion override (null = use default/neutral) */
|
||||
devEmotion: BlobbiEmotion | null;
|
||||
/** Set the dev emotion override */
|
||||
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
|
||||
/** Clear the dev emotion override (back to neutral) */
|
||||
clearDevEmotion: () => void;
|
||||
/** Whether dev emotion is active */
|
||||
isDevEmotionActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
|
||||
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,40 +52,4 @@ export function EmotionDevProvider({ children }: EmotionDevProviderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to access dev emotion state.
|
||||
* Returns null values in production for safety.
|
||||
*/
|
||||
export function useEmotionDev(): EmotionDevContextValue {
|
||||
const context = useContext(EmotionDevContext);
|
||||
|
||||
// Outside localhost dev or if no provider, return safe defaults
|
||||
if (!isLocalhostDev() || !context) {
|
||||
return {
|
||||
devEmotion: null,
|
||||
setDevEmotion: () => {},
|
||||
clearDevEmotion: () => {},
|
||||
isDevEmotionActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective emotion for a Blobbi.
|
||||
* In dev mode with an override, returns the dev emotion.
|
||||
* Otherwise returns the provided emotion or 'neutral'.
|
||||
*/
|
||||
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
|
||||
const { devEmotion, isDevEmotionActive } = useEmotionDev();
|
||||
|
||||
// Dev override takes precedence (only in localhost dev)
|
||||
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
|
||||
return devEmotion;
|
||||
}
|
||||
|
||||
return baseEmotion ?? 'neutral';
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
|
||||
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
|
||||
|
||||
// Emotion testing tools
|
||||
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
|
||||
export { EmotionDevProvider } from './EmotionDevContext';
|
||||
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
|
||||
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* IMPORTANT: This hook should only be used in development mode.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -24,8 +24,6 @@ interface UseBlobbiDevUpdateParams {
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Update companion event in local cache */
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
/** Invalidate companion queries */
|
||||
invalidateCompanion: () => void;
|
||||
}
|
||||
|
||||
interface DevUpdateResult {
|
||||
@@ -50,11 +48,9 @@ function generateBlobbiContent(name: string, stage: BlobbiStage): string {
|
||||
export function useBlobbiDevUpdate({
|
||||
companion,
|
||||
updateCompanionEvent,
|
||||
invalidateCompanion,
|
||||
}: UseBlobbiDevUpdateParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (updates: BlobbiDevUpdates): Promise<DevUpdateResult> => {
|
||||
@@ -169,12 +165,6 @@ export function useBlobbiDevUpdate({
|
||||
|
||||
// ─── Update Caches ───
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
|
||||
// Invalidate collection queries
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['blobbi-collection', user.pubkey]
|
||||
});
|
||||
|
||||
return {
|
||||
previousStage: companion.stage,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmotionDevContextValue {
|
||||
/** Current dev emotion override (null = use default/neutral) */
|
||||
devEmotion: BlobbiEmotion | null;
|
||||
/** Set the dev emotion override */
|
||||
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
|
||||
/** Clear the dev emotion override (back to neutral) */
|
||||
clearDevEmotion: () => void;
|
||||
/** Whether dev emotion is active */
|
||||
isDevEmotionActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
|
||||
|
||||
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to access dev emotion state.
|
||||
* Returns null values in production for safety.
|
||||
*/
|
||||
export function useEmotionDev(): EmotionDevContextValue {
|
||||
const context = useContext(EmotionDevContext);
|
||||
|
||||
// Outside localhost dev or if no provider, return safe defaults
|
||||
if (!isLocalhostDev() || !context) {
|
||||
return {
|
||||
devEmotion: null,
|
||||
setDevEmotion: () => {},
|
||||
clearDevEmotion: () => {},
|
||||
isDevEmotionActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective emotion for a Blobbi.
|
||||
* In dev mode with an override, returns the dev emotion.
|
||||
* Otherwise returns the provided emotion or 'neutral'.
|
||||
*/
|
||||
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
|
||||
const { devEmotion, isDevEmotionActive } = useEmotionDev();
|
||||
|
||||
// Dev override takes precedence (only in localhost dev)
|
||||
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
|
||||
return devEmotion;
|
||||
}
|
||||
|
||||
return baseEmotion ?? 'neutral';
|
||||
}
|
||||
@@ -438,3 +438,354 @@
|
||||
filter: grayscale(1) contrast(1.5) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Onboarding Hatching Ceremony Animations
|
||||
========================================== */
|
||||
|
||||
/* Soft breathing pulse for the egg before interaction */
|
||||
@keyframes egg-onboard-breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
filter: brightness(1) drop-shadow(0 0 20px rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.015);
|
||||
filter: brightness(1.03) drop-shadow(0 0 30px rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-breathe {
|
||||
animation: egg-onboard-breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Screen-filling radial glow that expands from center on hatch */
|
||||
@keyframes onboard-glow-expand {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
transform: scale(2.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-expand {
|
||||
animation: onboard-glow-expand 1.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle lingering glow fade after hatch - holds then fades */
|
||||
@keyframes onboard-glow-linger {
|
||||
0% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-glow-linger {
|
||||
animation: onboard-glow-linger 7s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Sentimental text fade in - very slow, dreamlike */
|
||||
@keyframes onboard-text-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-text-reveal {
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Delayed text reveal for secondary text */
|
||||
.animate-onboard-text-reveal-delay {
|
||||
opacity: 0;
|
||||
animation: onboard-text-reveal 1.8s cubic-bezier(0.22, 1, 0.36, 1) 0.6s forwards;
|
||||
}
|
||||
|
||||
/* Soft fade out for transition between phases */
|
||||
@keyframes onboard-soft-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-out {
|
||||
animation: onboard-soft-fade-out 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Soft fade in */
|
||||
@keyframes onboard-soft-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-soft-fade-in {
|
||||
animation: onboard-soft-fade-in 1s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Floating particles that drift upward from the egg */
|
||||
@keyframes onboard-particle-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.5);
|
||||
}
|
||||
20% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-120px) scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle twinkle - stays in place, pulses brightness */
|
||||
@keyframes onboard-sparkle-twinkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sparkle drift - gentle floating motion */
|
||||
@keyframes onboard-sparkle-drift {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.3);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-8px) scale(1);
|
||||
}
|
||||
80% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-25px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px) scale(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Egg entrance - subtle float up from darkness */
|
||||
@keyframes egg-onboard-entrance {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-entrance {
|
||||
animation: egg-onboard-entrance 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Egg shake intensifying - for crack stages */
|
||||
@keyframes egg-onboard-shake-light {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
25% { transform: translateX(-3px) rotate(-2deg); }
|
||||
75% { transform: translateX(3px) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-medium {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
20% { transform: translateX(-5px) rotate(-3deg); }
|
||||
40% { transform: translateX(4px) rotate(2deg); }
|
||||
60% { transform: translateX(-4px) rotate(-2deg); }
|
||||
80% { transform: translateX(5px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes egg-onboard-shake-heavy {
|
||||
0%, 100% { transform: translateX(0) rotate(0deg); }
|
||||
10% { transform: translateX(-6px) rotate(-4deg); }
|
||||
20% { transform: translateX(5px) rotate(3deg); }
|
||||
30% { transform: translateX(-7px) rotate(-3deg); }
|
||||
40% { transform: translateX(6px) rotate(4deg); }
|
||||
50% { transform: translateX(-5px) rotate(-2deg); }
|
||||
60% { transform: translateX(7px) rotate(3deg); }
|
||||
70% { transform: translateX(-6px) rotate(-4deg); }
|
||||
80% { transform: translateX(5px) rotate(2deg); }
|
||||
90% { transform: translateX(-4px) rotate(-3deg); }
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-light {
|
||||
animation: egg-onboard-shake-light 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-medium {
|
||||
animation: egg-onboard-shake-medium 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-egg-onboard-shake-heavy {
|
||||
animation: egg-onboard-shake-heavy 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* Final burst - egg explodes into light */
|
||||
@keyframes egg-onboard-burst {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.08);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
filter: brightness(2.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.3);
|
||||
opacity: 0;
|
||||
filter: brightness(4) blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-egg-onboard-burst {
|
||||
animation: egg-onboard-burst 1.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Screen flash on hatch */
|
||||
@keyframes onboard-screen-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-screen-flash {
|
||||
animation: onboard-screen-flash 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Gentle continue prompt pulse */
|
||||
@keyframes onboard-continue-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-continue-pulse {
|
||||
animation: onboard-continue-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slow rotating golden incandescence behind hatched blobbi */
|
||||
@keyframes onboard-golden-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(90deg) scale(1.06);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(270deg) scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-rotate {
|
||||
animation: onboard-golden-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Golden glow fade-in */
|
||||
@keyframes onboard-golden-fadein {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: onboard-golden-fadein 2.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
/* Reduced motion overrides for onboarding */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-egg-onboard-breathe,
|
||||
.animate-onboard-glow-expand,
|
||||
.animate-onboard-glow-linger,
|
||||
.animate-onboard-text-reveal,
|
||||
.animate-onboard-text-reveal-delay,
|
||||
.animate-onboard-soft-fade-out,
|
||||
.animate-onboard-soft-fade-in,
|
||||
.animate-egg-onboard-entrance,
|
||||
.animate-egg-onboard-shake-light,
|
||||
.animate-egg-onboard-shake-medium,
|
||||
.animate-egg-onboard-shake-heavy,
|
||||
.animate-egg-onboard-burst,
|
||||
.animate-onboard-screen-flash,
|
||||
.animate-onboard-continue-pulse,
|
||||
.animate-onboard-golden-rotate,
|
||||
.animate-onboard-golden-fadein {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* BlobbiHatchingCeremony - Immersive hatching experience for every new egg
|
||||
*
|
||||
* Flow:
|
||||
* 1. Dark screen, egg silently created in background
|
||||
* 2. Huge breathing egg appears. No text. No UI.
|
||||
* 3. Click egg 4 times through crack stages with intensifying shakes
|
||||
* 4. Final click -> egg bursts into light, actual hatch mutation fires
|
||||
* 5. Flash clears -> hatched baby blobbi revealed center screen with glow/sparkles
|
||||
* 6. Typewriter dialog appears below blobbi (click to complete line / advance)
|
||||
* 7. Naming prompt, then ceremony complete
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
INITIAL_BLOBBONAUT_COINS,
|
||||
STAT_MAX,
|
||||
buildBlobbonautTags,
|
||||
updateBlobbonautTags,
|
||||
updateBlobbiTags,
|
||||
type BlobbonautProfile,
|
||||
type BlobbiCompanion,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import {
|
||||
generateEggPreview,
|
||||
previewToEventTags,
|
||||
previewToBlobbiCompanion,
|
||||
type BlobbiEggPreview,
|
||||
} from '../lib/blobbi-preview';
|
||||
|
||||
// ─── Dialog Lines ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BIRTH_DIALOG: string[] = [
|
||||
'Something stirs...',
|
||||
'A tiny life has chosen you. It knows only warmth, and your presence.',
|
||||
];
|
||||
|
||||
const NAMING_DIALOG = 'Every life deserves a name.\nWhat will you call this one?';
|
||||
|
||||
// ─── Phase Machine ────────────────────────────────────────────────────────────
|
||||
|
||||
type CeremonyPhase =
|
||||
| 'loading'
|
||||
| 'egg'
|
||||
| 'crack_1'
|
||||
| 'crack_2'
|
||||
| 'crack_3'
|
||||
| 'hatching' // egg burst + hatch mutation
|
||||
| 'reveal' // flash clearing, baby blobbi fading in with glow
|
||||
| 'dialog' // typewriter dialog lines
|
||||
| 'naming'
|
||||
| 'complete';
|
||||
|
||||
// ─── Typewriter Hook ──────────────────────────────────────────────────────────
|
||||
|
||||
function useTypewriter(fullText: string, active: boolean, speed = 35) {
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const indexRef = useRef(0);
|
||||
|
||||
// Reset when text changes
|
||||
useEffect(() => {
|
||||
setDisplayed('');
|
||||
setDone(false);
|
||||
indexRef.current = 0;
|
||||
}, [fullText]);
|
||||
|
||||
// Run typewriter
|
||||
useEffect(() => {
|
||||
if (!active || done) return;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
indexRef.current++;
|
||||
const next = fullText.slice(0, indexRef.current);
|
||||
setDisplayed(next);
|
||||
if (indexRef.current >= fullText.length) {
|
||||
setDone(true);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
}
|
||||
}, speed);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [active, done, fullText, speed]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
setDisplayed(fullText);
|
||||
setDone(true);
|
||||
}, [fullText]);
|
||||
|
||||
return { displayed, done, complete };
|
||||
}
|
||||
|
||||
// Module-level guard: prevents duplicate egg creation if the component remounts
|
||||
// (e.g. React strict mode, parent re-render causing unmount/remount).
|
||||
// Tracks pubkeys that have already started setup in this browser session.
|
||||
const setupInFlightFor = new Set<string>();
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiHatchingCeremonyProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
onComplete?: () => void;
|
||||
/** If provided, skip egg creation and start from the cracking phase with this existing egg. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/** If true, only create the egg and skip the hatching ceremony. The egg stays an egg. */
|
||||
eggOnly?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiHatchingCeremony({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
existingCompanion,
|
||||
eggOnly = false,
|
||||
}: BlobbiHatchingCeremonyProps) {
|
||||
const isExistingEgg = !!existingCompanion;
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: authorData } = useAuthor(user?.pubkey);
|
||||
|
||||
// ── Core state ──
|
||||
const [phase, setPhase] = useState<CeremonyPhase>('loading');
|
||||
const [preview, setPreview] = useState<BlobbiEggPreview | null>(null);
|
||||
const [name, setName] = useState(existingCompanion?.name ?? '');
|
||||
const [isNaming, setIsNaming] = useState(false);
|
||||
const [eggVisible, setEggVisible] = useState(false);
|
||||
|
||||
// Reveal phase state
|
||||
const [blobbiVisible, setBlobbiVisible] = useState(false);
|
||||
const [showFlash, setShowFlash] = useState(false);
|
||||
const [, setShowRevealGlow] = useState(false);
|
||||
const [fadeOut, setFadeOut] = useState(false);
|
||||
|
||||
// Dialog state
|
||||
const [dialogLineIndex, setDialogLineIndex] = useState(0);
|
||||
const [dialogActive, setDialogActive] = useState(false);
|
||||
const [namingVisible, setNamingVisible] = useState(false);
|
||||
|
||||
// Refs
|
||||
const setupAttempted = useRef(false);
|
||||
const profileRef = useRef(profile);
|
||||
profileRef.current = profile;
|
||||
const previewRef = useRef(preview);
|
||||
previewRef.current = preview;
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const eggContainerRef = useRef<HTMLDivElement>(null);
|
||||
const entrancePlayed = useRef(false);
|
||||
const eggTagsRef = useRef<string[][] | null>(null);
|
||||
|
||||
// ── Companion visuals ──
|
||||
const eggCompanion = useMemo(
|
||||
() => preview ? previewToBlobbiCompanion(preview) : null,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[preview?.d],
|
||||
);
|
||||
|
||||
// Baby companion (same visual data but stage=baby)
|
||||
const babyCompanion = useMemo((): BlobbiCompanion | null => {
|
||||
if (!eggCompanion) return null;
|
||||
return { ...eggCompanion, stage: 'baby', state: 'active' };
|
||||
}, [eggCompanion]);
|
||||
|
||||
const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b';
|
||||
|
||||
// ── Typewriter for current dialog line ──
|
||||
const currentDialogText = phase === 'dialog' ? (BIRTH_DIALOG[dialogLineIndex] ?? '') : '';
|
||||
const dialogTypewriter = useTypewriter(currentDialogText, dialogActive);
|
||||
|
||||
const namingTypewriter = useTypewriter(NAMING_DIALOG, namingVisible);
|
||||
|
||||
// ── Fast-path setup for existing eggs (no publishing needed) ──
|
||||
useEffect(() => {
|
||||
if (!isExistingEgg || setupAttempted.current || !existingCompanion) return;
|
||||
setupAttempted.current = true;
|
||||
|
||||
// Build a minimal preview from the existing companion
|
||||
const fakePreview: BlobbiEggPreview = {
|
||||
d: existingCompanion.d,
|
||||
petId: existingCompanion.d,
|
||||
ownerPubkey: user?.pubkey ?? '',
|
||||
name: existingCompanion.name,
|
||||
stage: 'egg',
|
||||
state: 'active',
|
||||
seed: existingCompanion.seed ?? '',
|
||||
stats: {
|
||||
hunger: existingCompanion.stats.hunger ?? STAT_MAX,
|
||||
happiness: existingCompanion.stats.happiness ?? STAT_MAX,
|
||||
health: existingCompanion.stats.health ?? STAT_MAX,
|
||||
hygiene: existingCompanion.stats.hygiene ?? STAT_MAX,
|
||||
energy: existingCompanion.stats.energy ?? STAT_MAX,
|
||||
},
|
||||
visualTraits: existingCompanion.visualTraits,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
setPreview(fakePreview);
|
||||
previewRef.current = fakePreview;
|
||||
eggTagsRef.current = existingCompanion.allTags;
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isExistingEgg, existingCompanion?.d]);
|
||||
|
||||
// ── Silent setup: create profile + egg (new egg flow only) ──
|
||||
useEffect(() => {
|
||||
if (isExistingEgg) return; // Skip for existing eggs
|
||||
if (setupAttempted.current || !user?.pubkey) return;
|
||||
// Module-level guard: if another mount already started setup for this pubkey, skip
|
||||
if (setupInFlightFor.has(user.pubkey)) return;
|
||||
setupAttempted.current = true;
|
||||
setupInFlightFor.add(user.pubkey);
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const currentProfile = profileRef.current;
|
||||
let latestProfileTags: string[][] | null = currentProfile?.allTags ?? null;
|
||||
|
||||
// 1. Create profile if needed
|
||||
if (!currentProfile) {
|
||||
const suggestedName =
|
||||
authorData?.metadata?.display_name ||
|
||||
authorData?.metadata?.name ||
|
||||
'Blobbonaut';
|
||||
|
||||
const baseTags = buildBlobbonautTags(user.pubkey);
|
||||
const tagsWithName = [
|
||||
...baseTags,
|
||||
['name', suggestedName],
|
||||
['coins', INITIAL_BLOBBONAUT_COINS.toString()],
|
||||
];
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: tagsWithName,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
invalidateProfile();
|
||||
latestProfileTags = tagsWithName;
|
||||
}
|
||||
|
||||
// 2. Generate and publish egg
|
||||
const eggPreview = generateEggPreview(user.pubkey, 'Egg');
|
||||
setPreview(eggPreview);
|
||||
previewRef.current = eggPreview;
|
||||
|
||||
const eggTags = previewToEventTags(eggPreview);
|
||||
eggTagsRef.current = eggTags;
|
||||
|
||||
const eggEvent = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: 'A new Blobbi egg!',
|
||||
tags: eggTags,
|
||||
created_at: eggPreview.createdAt,
|
||||
});
|
||||
|
||||
updateCompanionEvent(eggEvent);
|
||||
|
||||
// 3. Update profile with has[] entry
|
||||
if (latestProfileTags) {
|
||||
const existingHas = latestProfileTags
|
||||
.filter(([k]) => k === 'has')
|
||||
.map(([, v]) => v);
|
||||
const newHas = [...existingHas, eggPreview.d];
|
||||
|
||||
const updatedTags = updateBlobbonautTags(latestProfileTags, {
|
||||
has: newHas,
|
||||
});
|
||||
|
||||
const updatedProfileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(updatedProfileEvent);
|
||||
}
|
||||
|
||||
setStoredSelectedD(eggPreview.d);
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
|
||||
setPhase('egg');
|
||||
setTimeout(() => setEggVisible(true), 200);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Setup failed:', error);
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Failed to set up your Blobbi. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
// Clear module-level guard so future adoptions can create new eggs
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(setup, 600);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// If the timer was cleared before setup ran, release the guard
|
||||
if (user?.pubkey) setupInFlightFor.delete(user.pubkey);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) profileRef.current = profile;
|
||||
}, [profile]);
|
||||
|
||||
// eggOnly mode: auto-complete after the egg is shown (skip hatching)
|
||||
useEffect(() => {
|
||||
if (!eggOnly || !eggVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eggOnly, eggVisible, onComplete]);
|
||||
|
||||
// Play entrance animation once
|
||||
useEffect(() => {
|
||||
if (eggVisible && !entrancePlayed.current && eggContainerRef.current) {
|
||||
entrancePlayed.current = true;
|
||||
const el = eggContainerRef.current;
|
||||
el.classList.add('animate-egg-onboard-entrance');
|
||||
const onEnd = () => {
|
||||
el.classList.remove('animate-egg-onboard-entrance');
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
}
|
||||
}, [eggVisible]);
|
||||
|
||||
// ── Shake (DOM-only, no re-render) ──
|
||||
const triggerShake = useCallback((cls: string) => {
|
||||
const el = eggContainerRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove(
|
||||
'animate-egg-onboard-shake-light',
|
||||
'animate-egg-onboard-shake-medium',
|
||||
'animate-egg-onboard-shake-heavy',
|
||||
);
|
||||
void el.offsetWidth;
|
||||
el.classList.add(cls);
|
||||
}, []);
|
||||
|
||||
// ── Execute the actual hatch: egg -> baby ──
|
||||
const executeHatch = useCallback(async () => {
|
||||
const tags = eggTagsRef.current;
|
||||
if (!tags) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nowStr = now.toString();
|
||||
|
||||
const babyTags = updateBlobbiTags(tags, {
|
||||
stage: 'baby',
|
||||
state: 'active',
|
||||
hunger: STAT_MAX.toString(),
|
||||
happiness: STAT_MAX.toString(),
|
||||
health: STAT_MAX.toString(),
|
||||
hygiene: STAT_MAX.toString(),
|
||||
energy: STAT_MAX.toString(),
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const babyName = previewRef.current?.name ?? 'Egg';
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${babyName} is a baby Blobbi.`,
|
||||
tags: babyTags,
|
||||
});
|
||||
|
||||
eggTagsRef.current = babyTags;
|
||||
updateCompanionEvent(event);
|
||||
invalidateCompanion();
|
||||
}, [publishEvent, updateCompanionEvent, invalidateCompanion]);
|
||||
|
||||
// ── Egg click ──
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (phase === 'egg') {
|
||||
triggerShake('animate-egg-onboard-shake-light');
|
||||
setPhase('crack_1');
|
||||
} else if (phase === 'crack_1') {
|
||||
triggerShake('animate-egg-onboard-shake-medium');
|
||||
setPhase('crack_2');
|
||||
} else if (phase === 'crack_2') {
|
||||
triggerShake('animate-egg-onboard-shake-heavy');
|
||||
setPhase('crack_3');
|
||||
} else if (phase === 'crack_3') {
|
||||
// Final click -> hatch!
|
||||
setPhase('hatching');
|
||||
setShowFlash(true);
|
||||
|
||||
// Fire the actual hatch mutation
|
||||
executeHatch().catch(console.error);
|
||||
|
||||
// After flash, reveal the baby
|
||||
setTimeout(() => {
|
||||
setShowFlash(false);
|
||||
setShowRevealGlow(true);
|
||||
setPhase('reveal');
|
||||
|
||||
// Fade in blobbi
|
||||
setTimeout(() => setBlobbiVisible(true), 400);
|
||||
|
||||
// After blobbi settles, start dialog
|
||||
setTimeout(() => {
|
||||
setPhase('dialog');
|
||||
setDialogLineIndex(0);
|
||||
setDialogActive(true);
|
||||
}, 2200);
|
||||
}, 1400);
|
||||
}
|
||||
}, [phase, triggerShake, executeHatch]);
|
||||
|
||||
// ── Dialog click: complete line or advance ──
|
||||
const handleDialogClick = useCallback(() => {
|
||||
if (phase !== 'dialog') return;
|
||||
|
||||
if (!dialogTypewriter.done) {
|
||||
// Complete the current line instantly
|
||||
dialogTypewriter.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance to next line
|
||||
const nextIndex = dialogLineIndex + 1;
|
||||
if (nextIndex < BIRTH_DIALOG.length) {
|
||||
setDialogActive(false);
|
||||
setDialogLineIndex(nextIndex);
|
||||
// Small pause before next line starts
|
||||
setTimeout(() => setDialogActive(true), 150);
|
||||
} else {
|
||||
// All lines done -> naming
|
||||
setDialogActive(false);
|
||||
setTimeout(() => {
|
||||
setPhase('naming');
|
||||
setTimeout(() => {
|
||||
setNamingVisible(true);
|
||||
setTimeout(() => nameInputRef.current?.focus(), 600);
|
||||
}, 200);
|
||||
}, 400);
|
||||
}
|
||||
}, [phase, dialogTypewriter, dialogLineIndex]);
|
||||
|
||||
// ── Complete ceremony ──
|
||||
const completeCeremony = useCallback(async (finalName: string) => {
|
||||
try {
|
||||
// Update egg/baby name if changed
|
||||
const currentTags = eggTagsRef.current;
|
||||
if (currentTags && finalName !== (previewRef.current?.name ?? 'Egg')) {
|
||||
const namedTags = updateBlobbiTags(currentTags, { name: finalName });
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
content: `${finalName} is a baby Blobbi.`,
|
||||
tags: namedTags,
|
||||
});
|
||||
updateCompanionEvent(event);
|
||||
}
|
||||
|
||||
// Mark onboarding done
|
||||
const currentProfile = profileRef.current;
|
||||
if (currentProfile) {
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
blobbi_onboarding_done: 'true',
|
||||
});
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
invalidateProfile();
|
||||
invalidateCompanion();
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Failed to persist completion:', error);
|
||||
}
|
||||
}, [publishEvent, updateCompanionEvent, updateProfileEvent, invalidateProfile, invalidateCompanion]);
|
||||
|
||||
// ── Naming submit ──
|
||||
const handleNameSubmit = useCallback(async () => {
|
||||
if (isNaming || !name.trim()) return;
|
||||
setIsNaming(true);
|
||||
|
||||
try {
|
||||
await completeCeremony(name.trim());
|
||||
setNamingVisible(false);
|
||||
// Fade to white, then complete
|
||||
setTimeout(() => {
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
console.error('[HatchingCeremony] Naming failed:', error);
|
||||
toast({
|
||||
title: 'Failed to save name',
|
||||
description: 'Your Blobbi was created, but the name could not be saved.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setFadeOut(true);
|
||||
setTimeout(() => {
|
||||
setPhase('complete');
|
||||
onComplete?.();
|
||||
}, 2200);
|
||||
} finally {
|
||||
setIsNaming(false);
|
||||
}
|
||||
}, [name, isNaming, completeCeremony, onComplete]);
|
||||
|
||||
// ── Tour visual state for EggGraphic crack rendering ──
|
||||
const tourVisualState = useMemo(() => {
|
||||
switch (phase) {
|
||||
case 'crack_1': return 'crack_stage_1' as const;
|
||||
case 'crack_2': return 'crack_stage_2' as const;
|
||||
case 'crack_3': return 'crack_stage_3' as const;
|
||||
case 'hatching': return 'opening' as const;
|
||||
default: return 'idle' as const;
|
||||
}
|
||||
}, [phase]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const isEggPhase = phase === 'egg' || phase === 'crack_1' || phase === 'crack_2' || phase === 'crack_3';
|
||||
const isHatching = phase === 'hatching';
|
||||
const showBaby = phase === 'reveal' || phase === 'dialog' || phase === 'naming';
|
||||
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)' }}
|
||||
>
|
||||
<div
|
||||
className="absolute size-32 rounded-full opacity-20 animate-pulse"
|
||||
style={{ background: `radial-gradient(circle, ${eggColor}40 0%, transparent 70%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-hidden select-none"
|
||||
style={{
|
||||
background: showBaby
|
||||
? 'radial-gradient(ellipse at 50% 45%, rgb(60,140,180) 0%, rgb(70,160,195) 25%, rgb(85,175,205) 50%, rgb(100,190,210) 75%, rgb(115,195,195) 100%)'
|
||||
: 'radial-gradient(ellipse at center, #0a1a2a 0%, #081520 50%, #060f18 100%)',
|
||||
transition: 'background 2s ease-out',
|
||||
}}
|
||||
onClick={phase === 'dialog' ? handleDialogClick : undefined}
|
||||
>
|
||||
{/* ── Ambient background glow (egg phase only) ── */}
|
||||
{!showBaby && (
|
||||
<div
|
||||
className="absolute inset-0 transition-opacity"
|
||||
style={{
|
||||
transitionDuration: '3000ms',
|
||||
background: `radial-gradient(ellipse at 50% 50%, ${eggColor}30 0%, transparent 60%)`,
|
||||
opacity: (isEggPhase || isHatching) ? 0.07 : 0.05,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Floating particles (egg phase) ── */}
|
||||
{isEggPhase && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
width: 2 + (i % 3),
|
||||
height: 2 + (i % 3),
|
||||
left: `${20 + (i * 12) % 60}%`,
|
||||
bottom: '40%',
|
||||
backgroundColor: `${eggColor}40`,
|
||||
animation: `onboard-particle-rise ${4 + i * 0.7}s ease-out ${i * 0.8}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The Egg ── */}
|
||||
{(isEggPhase || isHatching) && eggCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
ref={eggContainerRef}
|
||||
className={cn(
|
||||
'cursor-pointer relative',
|
||||
eggVisible ? '' : 'opacity-0',
|
||||
eggVisible && isEggPhase && 'animate-egg-onboard-breathe',
|
||||
isHatching && 'animate-egg-onboard-burst',
|
||||
)}
|
||||
onClick={isEggPhase ? handleEggClick : undefined}
|
||||
>
|
||||
<div
|
||||
className="absolute -inset-12 rounded-full blur-2xl transition-opacity duration-1000"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${eggColor}50 0%, transparent 70%)`,
|
||||
opacity: phase === 'crack_3' ? 0.5 : phase === 'crack_2' ? 0.35 : phase === 'crack_1' ? 0.25 : 0.15,
|
||||
}}
|
||||
/>
|
||||
<BlobbiStageVisual
|
||||
companion={eggCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-56 sm:size-64 md:size-72"
|
||||
tourVisualState={tourVisualState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Screen flash ── */}
|
||||
{showFlash && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white animate-onboard-screen-flash pointer-events-none"
|
||||
style={{ zIndex: 80 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Hatched baby blobbi with golden incandescence ── */}
|
||||
{showBaby && babyCompanion && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
style={{ paddingBottom: '18%' }}
|
||||
>
|
||||
{/* Rotating golden incandescence */}
|
||||
<div className={cn(
|
||||
'absolute animate-onboard-golden-fadein',
|
||||
blobbiVisible ? '' : 'opacity-0',
|
||||
)}>
|
||||
<div
|
||||
className="animate-onboard-golden-rotate"
|
||||
style={{
|
||||
width: 900,
|
||||
height: 900,
|
||||
background: `conic-gradient(
|
||||
from 0deg,
|
||||
rgba(255, 250, 230, 0.18) 0deg,
|
||||
rgba(255, 245, 210, 0.50) 50deg,
|
||||
rgba(255, 250, 235, 0.22) 100deg,
|
||||
rgba(255, 248, 220, 0.15) 150deg,
|
||||
rgba(255, 245, 210, 0.48) 210deg,
|
||||
rgba(255, 250, 230, 0.20) 270deg,
|
||||
rgba(255, 248, 220, 0.15) 320deg,
|
||||
rgba(255, 250, 230, 0.18) 360deg
|
||||
)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(30px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bright white-gold shine directly behind blobbi */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 320,
|
||||
height: 320,
|
||||
background: 'radial-gradient(circle, rgba(255,255,245,0.70) 0%, rgba(255,250,225,0.30) 40%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wider golden halo */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full transition-opacity [transition-duration:2000ms]',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
style={{
|
||||
width: 700,
|
||||
height: 700,
|
||||
background: 'radial-gradient(circle, rgba(255, 248, 210, 0.40) 0%, rgba(255, 240, 190, 0.18) 40%, transparent 65%)',
|
||||
filter: 'blur(15px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Sparkles everywhere ── */}
|
||||
|
||||
{/* Inner ring - bright twinkling sparkles */}
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const angle = (i / 20) * Math.PI * 2;
|
||||
const r = 80 + (i % 4) * 35;
|
||||
const size = 4 + (i % 3) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`inner-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 2 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,255,255,0.4) 40%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,130,1) 0%, rgba(255,220,80,0.3) 50%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${1.5 + (i % 6) * 0.5}s ease-in-out ${i * 0.15}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer ring - larger, slower sparkles */}
|
||||
{Array.from({ length: 16 }).map((_, i) => {
|
||||
const angle = (i / 16) * Math.PI * 2 + 0.3;
|
||||
const r = 170 + (i % 3) * 50;
|
||||
const size = 5 + (i % 4) * 3;
|
||||
return (
|
||||
<div
|
||||
key={`outer-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `calc(50% + ${Math.cos(angle) * r}px - ${size / 2}px)`,
|
||||
top: `calc(50% + ${Math.sin(angle) * r}px - ${size / 2}px)`,
|
||||
borderRadius: '50%',
|
||||
background: i % 3 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, transparent 60%)'
|
||||
: 'radial-gradient(circle, rgba(255,235,120,0.85) 0%, transparent 60%)',
|
||||
animation: `onboard-sparkle-twinkle ${2.5 + (i % 5) * 0.7}s ease-in-out ${i * 0.25}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scattered wide-field sparkles */}
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 2.7 + 1.3) * 0.5 + 0.5) * 80 + 10;
|
||||
const y = (Math.cos(i * 3.1 + 0.7) * 0.5 + 0.5) * 70 + 10;
|
||||
const size = 3 + (i % 3) * 2;
|
||||
return (
|
||||
<div
|
||||
key={`field-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
borderRadius: '50%',
|
||||
background: i % 4 === 0
|
||||
? 'radial-gradient(circle, rgba(255,255,255,0.95) 0%, transparent 70%)'
|
||||
: 'radial-gradient(circle, rgba(255,240,160,0.8) 0%, transparent 70%)',
|
||||
animation: `onboard-sparkle-twinkle ${2 + (i % 7) * 0.6}s ease-in-out ${i * 0.18}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drifting light motes rising from below */}
|
||||
{Array.from({ length: 10 }).map((_, i) => {
|
||||
const x = (Math.sin(i * 1.9) * 0.5 + 0.5) * 70 + 15;
|
||||
return (
|
||||
<div
|
||||
key={`drift-${i}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: 5 + (i % 3) * 3,
|
||||
height: 5 + (i % 3) * 3,
|
||||
left: `${x}%`,
|
||||
bottom: '20%',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(255,250,200,0.9) 0%, rgba(255,230,120,0.3) 50%, transparent 100%)',
|
||||
animation: `onboard-sparkle-drift ${4 + i * 0.5}s ease-out ${i * 0.5}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The baby blobbi */}
|
||||
<div className={cn(
|
||||
'relative transition-opacity duration-1000',
|
||||
blobbiVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}>
|
||||
<BlobbiStageVisual
|
||||
companion={babyCompanion}
|
||||
size="lg"
|
||||
animated
|
||||
className="size-[30rem] sm:size-[36rem] md:size-[44rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Dialog text (no box, blur behind) ── */}
|
||||
{phase === 'dialog' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className="relative max-w-md w-full text-center">
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Speaker */}
|
||||
<div className="relative">
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter text */}
|
||||
<p className="text-base sm:text-lg text-white leading-relaxed font-light min-h-[3em]">
|
||||
{dialogTypewriter.displayed}
|
||||
{!dialogTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Advance indicator */}
|
||||
{dialogTypewriter.done && (
|
||||
<div className="mt-4 animate-onboard-continue-pulse">
|
||||
<span className="text-xs text-white/30">▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Naming ── */}
|
||||
{phase === 'naming' && (
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pb-28 sm:pb-36 px-8">
|
||||
<div className={cn(
|
||||
'relative max-w-md w-full text-center',
|
||||
namingVisible ? 'animate-onboard-soft-fade-in' : 'opacity-0',
|
||||
)}>
|
||||
{/* Soft feathered backdrop with shadow */}
|
||||
<div
|
||||
className="absolute -inset-32"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, rgba(0,30,50,0.40) 0%, rgba(0,30,50,0.18) 35%, transparent 65%)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
mask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
WebkitMask: 'radial-gradient(ellipse at center, black 25%, transparent 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Speaker */}
|
||||
<p className="text-[11px] text-white/50 tracking-[0.2em] uppercase mb-3">
|
||||
???
|
||||
</p>
|
||||
|
||||
{/* Typewriter question */}
|
||||
<p className="text-base sm:text-lg text-white/85 leading-relaxed font-light mb-6 min-h-[1.5em] whitespace-pre-line">
|
||||
{namingTypewriter.displayed}
|
||||
{!namingTypewriter.done && (
|
||||
<span className="inline-block w-[2px] h-[1em] bg-white/50 ml-0.5 animate-pulse align-text-bottom" />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Input + confirm (appear after typewriter done) */}
|
||||
{namingTypewriter.done && (
|
||||
<div className="space-y-3 animate-onboard-soft-fade-in">
|
||||
<Input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="..."
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
className={cn(
|
||||
'text-center text-lg font-light h-12',
|
||||
'bg-white/10 border-transparent text-white placeholder:text-white/30',
|
||||
'focus:bg-white/[0.25] focus:border-transparent focus:ring-0 focus:outline-none',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'focus:shadow-[0_0_15px_rgba(255,255,255,0.15),0_0_40px_rgba(255,250,230,0.08)]',
|
||||
'transition-all duration-300',
|
||||
'rounded-full transition-shadow duration-500',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && name.trim()) handleNameSubmit();
|
||||
}}
|
||||
/>
|
||||
|
||||
{name.trim() && (
|
||||
<Button
|
||||
onClick={handleNameSubmit}
|
||||
disabled={isNaming}
|
||||
className={cn(
|
||||
'max-w-[12rem] mx-auto h-10 px-8 text-sm font-light tracking-wide',
|
||||
'bg-white/15 hover:bg-white/22 text-white/80 border-transparent',
|
||||
'rounded-full transition-all duration-300',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
)}
|
||||
variant="ghost"
|
||||
>
|
||||
That's the one.
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Fade to white on completion ── */}
|
||||
{fadeOut && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white pointer-events-none"
|
||||
style={{
|
||||
zIndex: 90,
|
||||
animation: 'blobbi-fade-to-white 2s ease-in forwards',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,19 @@
|
||||
/**
|
||||
* BlobbiOnboardingFlow - Main component that orchestrates the onboarding steps
|
||||
*
|
||||
* This component renders the appropriate onboarding step based on the user's
|
||||
* actual profile state. The initial step is derived from whether the profile
|
||||
* exists - not hardcoded.
|
||||
*
|
||||
* MODES:
|
||||
* 1. Full onboarding (default): Auto profile creation → Adoption question → Preview
|
||||
* 2. Adoption only (adoptionOnly=true): Skip directly to Preview for existing profiles
|
||||
*
|
||||
* IMPORTANT: This component should only be rendered when:
|
||||
* - User has no profile (auto-creates profile using kind 0 name)
|
||||
* - User has profile but no pets (shows adoption)
|
||||
* - User wants to adopt another Blobbi (adoptionOnly mode)
|
||||
*
|
||||
* Profile creation is now automatic - no manual name entry step is needed.
|
||||
* BlobbiOnboardingFlow - Immersive hatching ceremony for every new Blobbi
|
||||
*
|
||||
* Every new egg goes through the hatching ceremony - whether it's a user's
|
||||
* first Blobbi or their tenth. The ceremony creates the egg silently in the
|
||||
* background and presents a wordless, emotional hatching experience.
|
||||
*
|
||||
* The `adoptionOnly` prop is accepted for API compatibility but no longer
|
||||
* changes the flow - every egg gets the full ceremony.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useBlobbiOnboarding } from '../hooks/useBlobbiOnboarding';
|
||||
import { BlobbiAdoptionStep } from './BlobbiAdoptionStep';
|
||||
import { BlobbiEggPreviewCard } from './BlobbiEggPreviewCard';
|
||||
import { BlobbiAdoptionConfirmDialog } from './BlobbiAdoptionConfirmDialog';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BlobbiHatchingCeremony } from './BlobbiHatchingCeremony';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbonautProfile, BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
interface BlobbiOnboardingFlowProps {
|
||||
/** Current profile (null if doesn't exist) */
|
||||
@@ -43,9 +30,11 @@ interface BlobbiOnboardingFlowProps {
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
/** Called when onboarding is complete */
|
||||
onComplete?: () => void;
|
||||
/**
|
||||
* If true, skip profile creation and adoption question, go directly to preview.
|
||||
* Use this for "Adopt another Blobbi" flow for existing users.
|
||||
/** If provided, skip egg creation and use this existing egg for the ceremony. */
|
||||
existingCompanion?: BlobbiCompanion | null;
|
||||
/**
|
||||
* Accepted for API compatibility. Every new egg goes through the ceremony.
|
||||
* @deprecated No longer changes the flow.
|
||||
*/
|
||||
adoptionOnly?: boolean;
|
||||
}
|
||||
@@ -58,98 +47,20 @@ export function BlobbiOnboardingFlow({
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly = false,
|
||||
existingCompanion,
|
||||
adoptionOnly,
|
||||
}: BlobbiOnboardingFlowProps) {
|
||||
const [showAdoptConfirmDialog, setShowAdoptConfirmDialog] = useState(false);
|
||||
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
coins,
|
||||
} = useBlobbiOnboarding({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
invalidateProfile,
|
||||
invalidateCompanion,
|
||||
setStoredSelectedD,
|
||||
onComplete,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('[BlobbiOnboardingFlow] Rendering:', {
|
||||
hasProfile: !!profile,
|
||||
profileName: profile?.name,
|
||||
step: state.step,
|
||||
hasPreview: !!state.preview,
|
||||
adoptionOnly,
|
||||
});
|
||||
|
||||
// Handle adopt button click - show confirmation dialog
|
||||
const handleAdoptClick = () => {
|
||||
setShowAdoptConfirmDialog(true);
|
||||
};
|
||||
|
||||
// Handle confirm adoption
|
||||
const handleConfirmAdopt = async () => {
|
||||
await actions.adoptPreview();
|
||||
setShowAdoptConfirmDialog(false);
|
||||
};
|
||||
|
||||
// ─── Step: Auto Profile Creation ──────────────────────────────────────────────
|
||||
// Shows a loading state while profile is being auto-created
|
||||
if (state.step === 'creating-profile') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[300px] gap-4 p-8">
|
||||
<Loader2 className="size-10 text-primary animate-spin" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
Setting up your profile...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Adoption Question ──────────────────────────────────────────────────
|
||||
// Shown when profile exists but user has no pets yet
|
||||
if (state.step === 'adoption-question') {
|
||||
return (
|
||||
<BlobbiAdoptionStep
|
||||
blobbonautName={state.blobbonautName || profile?.name}
|
||||
onStartAdoption={actions.startAdoptionPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Egg Preview ────────────────────────────────────────────────────────
|
||||
// Shown when user is previewing/choosing an egg to adopt
|
||||
if (state.step === 'preview' && state.preview) {
|
||||
return (
|
||||
<>
|
||||
<BlobbiEggPreviewCard
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isFirstPreview={state.isFirstPreview}
|
||||
isProcessing={state.isProcessing}
|
||||
actionInProgress={state.actionInProgress === 'reroll' ? 'reroll' : state.actionInProgress === 'adopt' ? 'adopt' : null}
|
||||
onReroll={actions.rerollPreview}
|
||||
onAdopt={handleAdoptClick}
|
||||
onNameChange={actions.setPreviewName}
|
||||
/>
|
||||
|
||||
<BlobbiAdoptionConfirmDialog
|
||||
open={showAdoptConfirmDialog}
|
||||
onOpenChange={setShowAdoptConfirmDialog}
|
||||
preview={state.preview}
|
||||
coins={coins}
|
||||
isAdopting={state.isProcessing && state.actionInProgress === 'adopt'}
|
||||
onConfirm={handleConfirmAdopt}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen if parent logic is correct)
|
||||
console.warn('[BlobbiOnboardingFlow] Unexpected state - no matching step');
|
||||
return null;
|
||||
return (
|
||||
<BlobbiHatchingCeremony
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
updateCompanionEvent={updateCompanionEvent}
|
||||
invalidateProfile={invalidateProfile}
|
||||
invalidateCompanion={invalidateCompanion}
|
||||
setStoredSelectedD={setStoredSelectedD}
|
||||
onComplete={onComplete}
|
||||
existingCompanion={existingCompanion}
|
||||
eggOnly={adoptionOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Blobbi Onboarding Module
|
||||
*
|
||||
* Provides components and hooks for the Blobbi onboarding flow:
|
||||
* 1. Auto profile creation (using kind 0 name)
|
||||
* 2. Adoption question
|
||||
* 3. Egg preview with reroll/adopt
|
||||
*
|
||||
* Every new egg goes through the immersive hatching ceremony:
|
||||
* dark screen, huge egg, click-to-hatch, sentimental birth reveal, naming.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export { BlobbiAdoptionStep } from './components/BlobbiAdoptionStep';
|
||||
export { BlobbiEggPreviewCard } from './components/BlobbiEggPreviewCard';
|
||||
export { BlobbiAdoptionConfirmDialog } from './components/BlobbiAdoptionConfirmDialog';
|
||||
export { BlobbiOnboardingFlow } from './components/BlobbiOnboardingFlow';
|
||||
export { BlobbiHatchingCeremony } from './components/BlobbiHatchingCeremony';
|
||||
|
||||
// Hooks
|
||||
// Hooks (used internally; kept exported for potential external use)
|
||||
export { useBlobbiOnboarding } from './hooks/useBlobbiOnboarding';
|
||||
export type {
|
||||
OnboardingStep,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Package, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -20,7 +18,7 @@ import {
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ItemEffectDisplay } from './ItemEffectDisplay';
|
||||
@@ -31,228 +29,169 @@ interface BlobbiInventoryModalProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
/** The current companion (needed for stage-based restrictions) */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called when user wants to use an item. Opens the use flow. */
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
/** Called when user wants to use an item. Always uses once. */
|
||||
onUseItem?: (itemId: string) => void;
|
||||
/** Whether an item is currently being used */
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
/** Resolved inventory item with shop metadata and usability info */
|
||||
/** Resolved catalog item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
|
||||
// ── Shared items content (used by both standalone modal and unified shop modal) ──
|
||||
|
||||
interface BlobbiInventoryContentProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
onUseItem?: (itemId: string) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiInventoryContent({
|
||||
profile,
|
||||
profile: _profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
}: BlobbiInventoryContentProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showUseDialog, setShowUseDialog] = useState(false);
|
||||
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
const isEmpty = inventoryItems.length === 0;
|
||||
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
setQuantity(1);
|
||||
setShowUseDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || !onUseItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleCloseUseDialog = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else {
|
||||
setQuantity(Math.min(value, maxQuantity));
|
||||
}
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
|
||||
</p>
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for your Blobbi's current stage.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Badge */}
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSelectItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use Item Confirmation Dialog */}
|
||||
{selectedItem && companion && (
|
||||
<InventoryUseConfirmDialog
|
||||
open={showUseDialog}
|
||||
onOpenChange={handleCloseUseDialog}
|
||||
item={selectedItem}
|
||||
companion={companion}
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onQuantityChange={handleQuantityInput}
|
||||
onConfirm={handleConfirmUse}
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleUseItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isUsingItem ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,153 +237,3 @@ export function BlobbiInventoryModal({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
|
||||
|
||||
interface InventoryUseConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onConfirm: () => void;
|
||||
isUsing: boolean;
|
||||
}
|
||||
|
||||
function InventoryUseConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
companion,
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: InventoryUseConfirmDialogProps) {
|
||||
const totalEffect = useMemo(() => {
|
||||
if (!item.effect) return null;
|
||||
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
if (delta !== undefined) {
|
||||
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
if (delta !== 0) {
|
||||
result[stat] = delta;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}, [item.effect, companion.stats, quantity]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Use Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Item Preview */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onDecrease}
|
||||
disabled={quantity <= 1 || isUsing}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
value={quantity}
|
||||
onChange={onQuantityChange}
|
||||
disabled={isUsing}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onIncrease}
|
||||
disabled={quantity >= maxQuantity || isUsing}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{totalEffect && (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
|
||||
</h4>
|
||||
<ItemEffectDisplay effect={totalEffect} variant="badges" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUsing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isUsing}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isUsing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Using...
|
||||
</>
|
||||
) : (
|
||||
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,16 @@ import {
|
||||
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
type TopTab = 'items' | 'shop';
|
||||
|
||||
/** Resolved inventory item with shop metadata and usability info */
|
||||
/** Resolved catalog item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
@@ -39,7 +38,7 @@ interface BlobbiShopModalProps {
|
||||
initialTab?: TopTab;
|
||||
// ── Inventory props (passed through) ──
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
onUseItem?: (itemId: string) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
@@ -80,28 +79,24 @@ export function BlobbiShopModal({
|
||||
|
||||
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
|
||||
|
||||
// ── Inventory items resolution ──
|
||||
// ── Items resolution — sourced from the full catalog (not inventory) ──
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allCatalogItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allCatalogItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
// ── Inventory use item handler ──
|
||||
const [usingItemId, setUsingItemId] = useState<string | null>(null);
|
||||
@@ -109,7 +104,7 @@ export function BlobbiShopModal({
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
setUsingItemId(item.itemId);
|
||||
onUseItem(item.itemId, 1);
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
// Clear usingItemId when isUsingItem goes false
|
||||
@@ -138,7 +133,7 @@ export function BlobbiShopModal({
|
||||
Items
|
||||
{!inventoryEmpty && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
|
||||
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
|
||||
{inventoryItems.length}
|
||||
</Badge>
|
||||
)}
|
||||
{topTab === 'items' && (
|
||||
@@ -265,7 +260,7 @@ function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridPr
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
|
||||
// ─── Items Grid (catalog, tile layout) ────────────────────────────────────────
|
||||
|
||||
interface ItemsGridProps {
|
||||
items: ResolvedInventoryItem[];
|
||||
@@ -275,20 +270,16 @@ interface ItemsGridProps {
|
||||
onGoToShop: () => void;
|
||||
}
|
||||
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _onGoToShop }: ItemsGridProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No items yet. Visit the shop to stock up!
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No items are available for your Blobbi's current stage.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
|
||||
<ShoppingBag className="size-3.5" />
|
||||
Browse Shop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,13 +299,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
|
||||
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Quantity badge */}
|
||||
<Badge
|
||||
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
|
||||
>
|
||||
{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Used by:
|
||||
* - BlobbiShopItemRow (shop listing)
|
||||
* - BlobbiPurchaseDialog (purchase confirmation)
|
||||
* - BlobbiInventoryModal (inventory listing)
|
||||
* - BlobbiInventoryModal (items listing)
|
||||
* - BlobbiActionInventoryModal (action item selection)
|
||||
*
|
||||
* All consumers should use this component to ensure consistent display of item effects.
|
||||
@@ -192,30 +192,6 @@ export function ItemEffectDisplay({
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Utility Exports ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format effects as a summary string (for compatibility with existing code).
|
||||
* This is a drop-in replacement for formatEffectSummary in blobbi-shop-utils.ts.
|
||||
*
|
||||
* @deprecated Use <ItemEffectDisplay variant="inline" /> instead
|
||||
*/
|
||||
export function formatEffectSummary(effect: ItemEffect | undefined, maxEffects = 4): string {
|
||||
const entries = getSortedEffectEntries(effect);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return 'No effects';
|
||||
}
|
||||
|
||||
const displayEntries = maxEffects !== undefined ? entries.slice(0, maxEffects) : entries;
|
||||
|
||||
return displayEntries
|
||||
.map(([stat, value]) => `${formatStatValue(value)} ${STAT_LABELS[stat]}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted effect entries for custom rendering.
|
||||
* Useful when you need to iterate over effects yourself.
|
||||
*/
|
||||
export { getSortedEffectEntries };
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* FirstHatchTourCard - Inline card shown below the egg during the first-hatch tour.
|
||||
*
|
||||
* Rendered directly in the BlobbiPage layout so the experience feels
|
||||
* focused and guided. Adapts its messaging based on the current tour step.
|
||||
*
|
||||
* When the post mission is completed, the card stays visible with a
|
||||
* celebratory completed state for ~2s (the parent auto-advances after
|
||||
* that delay). This ensures the user sees the checkmark before the
|
||||
* flow progresses to the egg-tap phase.
|
||||
*/
|
||||
|
||||
import { Send, Check, MousePointerClick } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import type { FirstHatchTourStepId } from '../lib/tour-types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourCardProps {
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Current tour step id for adaptive messaging */
|
||||
currentStep: FirstHatchTourStepId | null;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourCard({
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
currentStep,
|
||||
}: FirstHatchTourCardProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
// Determine which phase of the card to show
|
||||
const isPostStep = currentStep === 'show_hatch_card';
|
||||
const isClickStep = currentStep === 'egg_glowing_waiting_click'
|
||||
|| currentStep === 'egg_crack_stage_1'
|
||||
|| currentStep === 'egg_crack_stage_2'
|
||||
|| currentStep === 'egg_crack_stage_3';
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm mx-auto space-y-4">
|
||||
{/* Title + description */}
|
||||
<div className="text-center space-y-1.5">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{isClickStep
|
||||
? `Tap ${capitalizedName} to hatch!`
|
||||
: postCompleted && isPostStep
|
||||
? `${capitalizedName} heard you!`
|
||||
: `${capitalizedName} is ready to hatch!`}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{isClickStep
|
||||
? `Tap the egg to help ${capitalizedName} break free.`
|
||||
: postCompleted && isPostStep
|
||||
? 'Your post was shared. Get ready to hatch...'
|
||||
: `Share a post to the Nostr network and help ${capitalizedName} break free.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card - only during post step */}
|
||||
{isPostStep && (
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
{postCompleted ? (
|
||||
/* ── Completed state — celebratory, stays visible ── */
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div className="size-10 rounded-full bg-emerald-500/15 flex items-center justify-center">
|
||||
<Check className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
Post shared!
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Continuing in a moment...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Pending state — post mission ── */
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">Share a hatch post</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your post must include:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tap hint during click steps */}
|
||||
{isClickStep && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<MousePointerClick className="size-4" />
|
||||
<span>Tap the egg</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra hint for post step */}
|
||||
{isPostStep && !postCompleted && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* FirstHatchTourModal - Modal shown during the `show_hatch_modal` tour step.
|
||||
*
|
||||
* Tells the user their egg is about to hatch and guides them to create a post.
|
||||
* Contains a single mission: create the hatch post.
|
||||
*/
|
||||
|
||||
import { Egg, Send, Check } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FirstHatchTourModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi's display name */
|
||||
blobbiName: string;
|
||||
/** The exact phrase the user needs to include in their post */
|
||||
requiredPhrase: string;
|
||||
/** Whether the post mission has been completed */
|
||||
postCompleted: boolean;
|
||||
/** Open the post composer */
|
||||
onCreatePost: () => void;
|
||||
/** Advance the tour (called after post is confirmed complete) */
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FirstHatchTourModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
blobbiName,
|
||||
requiredPhrase,
|
||||
postCompleted,
|
||||
onCreatePost,
|
||||
onContinue,
|
||||
}: FirstHatchTourModalProps) {
|
||||
const capitalizedName = blobbiName.charAt(0).toUpperCase() + blobbiName.slice(1);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header with egg accent */}
|
||||
<div className="px-6 pt-8 pb-4 text-center space-y-3">
|
||||
<div className="mx-auto size-14 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<Egg className="size-7 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{capitalizedName} is ready to hatch!
|
||||
</DialogTitle>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Share a post to the Nostr network and help {capitalizedName} break free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mission card */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="rounded-xl border bg-card p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className={
|
||||
postCompleted
|
||||
? 'mt-0.5 size-5 rounded-full bg-emerald-500/15 flex items-center justify-center shrink-0'
|
||||
: 'mt-0.5 size-5 rounded-full border-2 border-muted-foreground/30 shrink-0'
|
||||
}>
|
||||
{postCompleted && <Check className="size-3 text-emerald-500" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{postCompleted ? 'Post shared!' : 'Share a hatch post'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Post must include the phrase:
|
||||
</p>
|
||||
<p className="text-xs font-medium text-primary break-words">
|
||||
{requiredPhrase}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!postCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Send className="size-3.5 mr-2" />
|
||||
Create Post
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6">
|
||||
{postCompleted ? (
|
||||
<Button className="w-full" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
You can add extra text before or after the required phrase.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* useFirstHatchTour - State machine for the first-egg hatch tutorial.
|
||||
*
|
||||
* Orchestration only -- no rendering, no animations.
|
||||
* The hook manages:
|
||||
* - Ordered step progression
|
||||
* - Persisted state via localStorage (survives refresh / close)
|
||||
* - Derived booleans for UI consumption
|
||||
* - Safe advance / goTo / complete / reset actions
|
||||
*
|
||||
* Activation is handled separately by useFirstHatchTourActivation,
|
||||
* which calls `start()` when all preconditions are met.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Future integration points
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. BlobbiPage (or a wrapper) calls useFirstHatchTourActivation
|
||||
* to decide whether to start the tour.
|
||||
* 2. UI components read `state.currentStepId` and render overlays,
|
||||
* spotlights, modals, or animation cues accordingly.
|
||||
* 3. Animation components call `actions.advance()` when their
|
||||
* sequence finishes (for autoAdvance steps).
|
||||
* 4. Interactive steps (e.g. "click the egg") call `actions.advance()`
|
||||
* on the user interaction.
|
||||
* 5. EggGraphic receives a visual-state prop derived from
|
||||
* `state.currentStepId` -- it does NOT own the tour logic.
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
|
||||
import {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
type FirstHatchTourStepId,
|
||||
type FirstHatchTourPersistedState,
|
||||
type TourState,
|
||||
type TourActions,
|
||||
} from '../lib/tour-types';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* localStorage key for the first hatch tour state.
|
||||
* Not user-scoped because onboarding state is device-local and the tour
|
||||
* is inherently tied to "first ever egg on this device". If multi-user
|
||||
* support on the same device becomes a concern, scope by pubkey.
|
||||
*/
|
||||
const STORAGE_KEY = 'blobbi:tour:first-hatch';
|
||||
|
||||
/** Pre-computed lookup: stepId -> index */
|
||||
const STEP_INDEX_MAP = new Map<FirstHatchTourStepId, number>(
|
||||
FIRST_HATCH_TOUR_STEPS.map((step, i) => [step.id, i]),
|
||||
);
|
||||
|
||||
/** Index of the last step that is NOT the terminal 'complete' pseudo-step */
|
||||
const LAST_REAL_STEP_INDEX = FIRST_HATCH_TOUR_STEPS.length - 2;
|
||||
|
||||
// ─── Result Type ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseFirstHatchTourResult {
|
||||
/** Reactive tour state for UI consumption */
|
||||
state: TourState<FirstHatchTourStepId>;
|
||||
/** Actions to drive the tour forward */
|
||||
actions: TourActions<FirstHatchTourStepId>;
|
||||
/**
|
||||
* Convenience: check if the current step matches a given id.
|
||||
* Useful for conditional rendering: `isStep('egg_crack_stage_1')`.
|
||||
*/
|
||||
isStep: (stepId: FirstHatchTourStepId) => boolean;
|
||||
/**
|
||||
* Convenience: check if the current step is one of the given ids.
|
||||
* Useful for grouping: `isAnyStep('egg_crack_stage_1', 'egg_crack_stage_2', 'egg_crack_stage_3')`.
|
||||
*/
|
||||
isAnyStep: (...stepIds: FirstHatchTourStepId[]) => boolean;
|
||||
/**
|
||||
* The current step definition (with autoAdvance metadata), or null.
|
||||
*/
|
||||
currentStepDef: (typeof FIRST_HATCH_TOUR_STEPS)[number] | null;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFirstHatchTour(): UseFirstHatchTourResult {
|
||||
// ── Persisted state ──
|
||||
const [persisted, setPersisted] = useLocalStorage<FirstHatchTourPersistedState>(
|
||||
STORAGE_KEY,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
);
|
||||
|
||||
// Stable ref to current persisted state so callbacks never go stale.
|
||||
const persistedRef = useRef(persisted);
|
||||
persistedRef.current = persisted;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const updatePersisted = useCallback(
|
||||
(patch: Partial<FirstHatchTourPersistedState>) => {
|
||||
setPersisted((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
},
|
||||
[setPersisted],
|
||||
);
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const start = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
// No-op if already active or completed
|
||||
if (p.completed || p.currentStepId !== null) return;
|
||||
|
||||
const firstStep = FIRST_HATCH_TOUR_STEPS[0];
|
||||
if (!firstStep) return;
|
||||
|
||||
updatePersisted({ currentStepId: firstStep.id });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
const p = persistedRef.current;
|
||||
if (p.completed || p.currentStepId === null) return;
|
||||
|
||||
const currentIndex = STEP_INDEX_MAP.get(p.currentStepId);
|
||||
if (currentIndex === undefined) return;
|
||||
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= FIRST_HATCH_TOUR_STEPS.length) {
|
||||
// Past the end -- complete
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = FIRST_HATCH_TOUR_STEPS[nextIndex];
|
||||
if (nextStep.id === 'complete') {
|
||||
// Reaching the 'complete' terminal step means the tour is done
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: nextStep.id });
|
||||
}
|
||||
}, [updatePersisted]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => {
|
||||
if (!STEP_INDEX_MAP.has(stepId)) {
|
||||
throw new Error(`[FirstHatchTour] Unknown step id: "${stepId}"`);
|
||||
}
|
||||
|
||||
if (stepId === 'complete') {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
} else {
|
||||
updatePersisted({ currentStepId: stepId, completed: false });
|
||||
}
|
||||
},
|
||||
[updatePersisted],
|
||||
);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
updatePersisted({ currentStepId: null, completed: true });
|
||||
}, [updatePersisted]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPersisted(FIRST_HATCH_TOUR_DEFAULT_STATE);
|
||||
}, [setPersisted]);
|
||||
|
||||
// ── Derived state ──
|
||||
|
||||
const currentStepIndex = persisted.currentStepId !== null
|
||||
? (STEP_INDEX_MAP.get(persisted.currentStepId) ?? -1)
|
||||
: -1;
|
||||
|
||||
const state = useMemo((): TourState<FirstHatchTourStepId> => {
|
||||
const isActive = persisted.currentStepId !== null && !persisted.completed;
|
||||
const totalSteps = FIRST_HATCH_TOUR_STEPS.length;
|
||||
|
||||
return {
|
||||
isActive,
|
||||
currentStepId: persisted.currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
isLastStep: currentStepIndex === LAST_REAL_STEP_INDEX,
|
||||
isCompleted: persisted.completed,
|
||||
progress: persisted.completed
|
||||
? 1
|
||||
: currentStepIndex >= 0
|
||||
? currentStepIndex / LAST_REAL_STEP_INDEX
|
||||
: 0,
|
||||
};
|
||||
}, [persisted.currentStepId, persisted.completed, currentStepIndex]);
|
||||
|
||||
const actions = useMemo((): TourActions<FirstHatchTourStepId> => ({
|
||||
start,
|
||||
advance,
|
||||
goTo,
|
||||
complete,
|
||||
reset,
|
||||
}), [start, advance, goTo, complete, reset]);
|
||||
|
||||
// ── Convenience helpers ──
|
||||
|
||||
const isStep = useCallback(
|
||||
(stepId: FirstHatchTourStepId) => persisted.currentStepId === stepId,
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const isAnyStep = useCallback(
|
||||
(...stepIds: FirstHatchTourStepId[]) => {
|
||||
return persisted.currentStepId !== null && stepIds.includes(persisted.currentStepId);
|
||||
},
|
||||
[persisted.currentStepId],
|
||||
);
|
||||
|
||||
const currentStepDef = currentStepIndex >= 0
|
||||
? FIRST_HATCH_TOUR_STEPS[currentStepIndex]
|
||||
: null;
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
isStep,
|
||||
isAnyStep,
|
||||
currentStepDef,
|
||||
};
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* useFirstHatchTourActivation - Activation guard for the first-egg hatch tour.
|
||||
*
|
||||
* This hook checks all preconditions and calls `tour.actions.start()` when
|
||||
* the tour should activate. It is intentionally separated from the tour
|
||||
* state machine so that:
|
||||
* - The state machine stays generic and reusable.
|
||||
* - Activation rules are centralized in one place.
|
||||
* - The rules are easy to read and modify.
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* Activation rules (ALL must be true):
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 1. The companions list is loaded (not loading / error).
|
||||
* 2. The user has exactly 1 Blobbi.
|
||||
* 3. That Blobbi is in the egg stage.
|
||||
* 4. No Blobbi is in baby or adult stage.
|
||||
* 5. The tour has not been completed yet (checked via profile tag
|
||||
* AND localStorage fallback).
|
||||
*
|
||||
* Completion is authoritative from the Blobbonaut profile event
|
||||
* (`blobbi_onboarding_done` tag). localStorage (`blobbi:tour:first-hatch`)
|
||||
* is a secondary signal for in-progress UI state and as a fallback
|
||||
* when the profile hasn't been updated yet.
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
import type { UseFirstHatchTourResult } from './useFirstHatchTour';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirstHatchTourActivationInput {
|
||||
/** The full list of the user's Blobbi companions */
|
||||
companions: BlobbiCompanion[];
|
||||
/** Whether the companions list is still loading */
|
||||
isLoading: boolean;
|
||||
/** The tour hook result (localStorage-based state machine) */
|
||||
tour: UseFirstHatchTourResult;
|
||||
/**
|
||||
* Whether onboarding is already marked complete in the Blobbonaut profile
|
||||
* event (`blobbi_onboarding_done` tag). This is the authoritative source.
|
||||
* When true, the tour will not activate regardless of localStorage state.
|
||||
*/
|
||||
profileOnboardingDone?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstHatchTourActivationResult {
|
||||
/**
|
||||
* Whether all preconditions for activating the tour are met right now.
|
||||
* This is a derived boolean -- it does NOT mean the tour IS active,
|
||||
* just that it SHOULD be activated. The tour may already be active
|
||||
* from a previous render or a persisted state.
|
||||
*/
|
||||
shouldActivate: boolean;
|
||||
/**
|
||||
* Whether the tour is eligible (preconditions met and not yet completed).
|
||||
* Useful for hiding UI that should only appear during the tour window.
|
||||
*/
|
||||
isEligible: boolean;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluates activation preconditions and auto-starts the tour when met.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const tour = useFirstHatchTour();
|
||||
* const activation = useFirstHatchTourActivation({
|
||||
* companions,
|
||||
* isLoading: companionsLoading,
|
||||
* tour,
|
||||
* profileOnboardingDone: profile?.onboardingDone,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useFirstHatchTourActivation({
|
||||
companions,
|
||||
isLoading,
|
||||
tour,
|
||||
profileOnboardingDone: _profileOnboardingDone = false,
|
||||
}: FirstHatchTourActivationInput): FirstHatchTourActivationResult {
|
||||
// ── Precondition evaluation ──
|
||||
|
||||
const { shouldActivate, isEligible } = useMemo(() => {
|
||||
// Can't evaluate until data is loaded
|
||||
if (isLoading) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// localStorage tour already completed — this is always authoritative
|
||||
if (tour.state.isCompleted) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// Must have exactly 1 companion
|
||||
if (companions.length !== 1) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
const onlyBlobbi = companions[0];
|
||||
|
||||
// That companion must be an egg
|
||||
if (onlyBlobbi.stage !== 'egg') {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// No baby or adult companions (redundant given length === 1 + stage === 'egg',
|
||||
// but kept explicit for clarity and future-proofing if rules change)
|
||||
const hasBabyOrAdult = companions.some(
|
||||
(c) => c.stage === 'baby' || c.stage === 'adult',
|
||||
);
|
||||
if (hasBabyOrAdult) {
|
||||
return { shouldActivate: false, isEligible: false };
|
||||
}
|
||||
|
||||
// ── TEMPORARY MIGRATION SAFEGUARD ──────────────────────────────
|
||||
// Some older accounts had `onboarding_done` migrated to
|
||||
// `blobbi_onboarding_done=true` before the first-hatch tour
|
||||
// existed, so they never experienced it. When the user is in the
|
||||
// exact single-egg/no-evolved-companions state (all checks above
|
||||
// passed), we intentionally ignore `profileOnboardingDone` so
|
||||
// those accounts can still enter the tour.
|
||||
//
|
||||
// This is safe because:
|
||||
// - The localStorage `tour.state.isCompleted` check above
|
||||
// already prevents re-triggering for users who HAVE finished
|
||||
// the tour.
|
||||
// - The egg-stage + single-companion guard means this only
|
||||
// fires for users who genuinely haven't hatched yet.
|
||||
//
|
||||
// TODO: Replace `blobbi_onboarding_done` with a dedicated
|
||||
// `blobbi_first_hatch_tour_done` tag so onboarding completion
|
||||
// and tour completion are tracked independently. Once that tag
|
||||
// is in place, remove this safeguard and gate activation on the
|
||||
// new tag instead.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// (profileOnboardingDone is intentionally NOT checked here)
|
||||
|
||||
// All preconditions met
|
||||
const eligible = true;
|
||||
// Only activate if the tour is not already running
|
||||
const activate = !tour.state.isActive;
|
||||
|
||||
return { shouldActivate: activate, isEligible: eligible };
|
||||
}, [isLoading, companions, tour.state.isCompleted, tour.state.isActive]);
|
||||
|
||||
// ── Auto-start effect ──
|
||||
// When all preconditions are met and the tour hasn't started yet,
|
||||
// start it. This fires once and then `shouldActivate` flips to false
|
||||
// because `tour.state.isActive` becomes true.
|
||||
useEffect(() => {
|
||||
if (shouldActivate) {
|
||||
tour.actions.start();
|
||||
}
|
||||
}, [shouldActivate, tour.actions]);
|
||||
|
||||
return { shouldActivate, isEligible };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Blobbi Tour Module
|
||||
*
|
||||
* Provides the orchestration layer for guided tours / tutorials.
|
||||
* Currently implements the first-egg hatch tour.
|
||||
*
|
||||
* Architecture:
|
||||
* - tour-types.ts: Step definitions, persisted state shape, generic types
|
||||
* - useFirstHatchTour: State machine (step progression, persistence, actions)
|
||||
* - useFirstHatchTourActivation: Precondition guard (auto-starts when eligible)
|
||||
*
|
||||
* UI components import from this barrel and read tour state to decide
|
||||
* what to render. They call tour actions (advance, goTo, complete) in
|
||||
* response to user interactions or animation completions.
|
||||
*/
|
||||
|
||||
// ── Types (generic tour infrastructure) ──
|
||||
export type {
|
||||
TourStepDef,
|
||||
TourPersistedState,
|
||||
TourState,
|
||||
TourActions,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Types & Constants ──
|
||||
export {
|
||||
FIRST_HATCH_TOUR_STEPS,
|
||||
FIRST_HATCH_TOUR_DEFAULT_STATE,
|
||||
} from './lib/tour-types';
|
||||
export type {
|
||||
FirstHatchTourStepId,
|
||||
FirstHatchTourPersistedState,
|
||||
} from './lib/tour-types';
|
||||
|
||||
// ── First Hatch Tour - Hooks ──
|
||||
export { useFirstHatchTour } from './hooks/useFirstHatchTour';
|
||||
export type { UseFirstHatchTourResult } from './hooks/useFirstHatchTour';
|
||||
|
||||
export { useFirstHatchTourActivation } from './hooks/useFirstHatchTourActivation';
|
||||
export type {
|
||||
FirstHatchTourActivationInput,
|
||||
FirstHatchTourActivationResult,
|
||||
} from './hooks/useFirstHatchTourActivation';
|
||||
|
||||
// ── First Hatch Tour - Components ──
|
||||
export { FirstHatchTourCard } from './components/FirstHatchTourCard';
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Tour System - Core Types
|
||||
*
|
||||
* Generic, reusable types for step-based guided tours.
|
||||
* The tour system is designed to be:
|
||||
* - Easy to extend with new tours (define steps + config)
|
||||
* - Easy to reorder steps (change the STEPS array)
|
||||
* - Persistent across page refreshes (localStorage)
|
||||
* - Decoupled from rendering (UI reads state, doesn't own it)
|
||||
*/
|
||||
|
||||
// ─── Generic Tour Infrastructure ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A tour step definition.
|
||||
*
|
||||
* Each step has a unique id and optional metadata that future UI layers
|
||||
* can use to decide what to render (spotlights, modals, animations, etc.).
|
||||
*/
|
||||
export interface TourStepDef<StepId extends string = string> {
|
||||
/** Unique identifier for this step */
|
||||
id: StepId;
|
||||
/**
|
||||
* Whether this step auto-advances (e.g. animations) or waits for
|
||||
* an explicit `advance()` / `goTo()` call from the UI.
|
||||
* Default: false (manual).
|
||||
*/
|
||||
autoAdvance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted state for a tour.
|
||||
* Stored in localStorage so tours survive refresh / close / return.
|
||||
*/
|
||||
export interface TourPersistedState<StepId extends string = string> {
|
||||
/** Current step id, or null when the tour is not yet started */
|
||||
currentStepId: StepId | null;
|
||||
/** Whether the tour has been completed */
|
||||
completed: boolean;
|
||||
/** Unix ms timestamp of last state change (for debugging / analytics) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full runtime state exposed by a tour hook.
|
||||
*/
|
||||
export interface TourState<StepId extends string = string> {
|
||||
/** Whether the tour is currently active (started and not yet completed) */
|
||||
isActive: boolean;
|
||||
/** Current step id, or null when idle / completed */
|
||||
currentStepId: StepId | null;
|
||||
/** 0-based index of the current step in the steps array, or -1 */
|
||||
currentStepIndex: number;
|
||||
/** Total number of steps */
|
||||
totalSteps: number;
|
||||
/** Whether the current step is the last one before completion */
|
||||
isLastStep: boolean;
|
||||
/** Whether the tour has been completed (persisted) */
|
||||
isCompleted: boolean;
|
||||
/** Progress as a fraction 0..1 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions exposed by a tour hook.
|
||||
*/
|
||||
export interface TourActions<StepId extends string = string> {
|
||||
/** Start the tour from the first step (no-op if already active or completed) */
|
||||
start: () => void;
|
||||
/** Advance to the next step. Completes the tour if on the last step. */
|
||||
advance: () => void;
|
||||
/** Jump to a specific step by id. Throws if the step doesn't exist. */
|
||||
goTo: (stepId: StepId) => void;
|
||||
/** Mark the tour as completed and reset to idle. */
|
||||
complete: () => void;
|
||||
/** Reset the tour entirely (clears persisted state). For dev/testing. */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ─── First Hatch Tour ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step ids for the first-egg hatch tour.
|
||||
*
|
||||
* Flow:
|
||||
* 1. idle — initial state (auto-advances immediately)
|
||||
* 2. show_hatch_card — egg with initial crack + wiggle + inline card
|
||||
* 3. egg_glowing_waiting_click — post done, egg glows, waiting for user click
|
||||
* 4. egg_crack_stage_1 — click 1: crack expands
|
||||
* 5. egg_crack_stage_2 — click 2: crack expands further
|
||||
* 6. egg_crack_stage_3 — click 3: crack reaches edges
|
||||
* 7. egg_opening — shell opens (auto-advance after animation)
|
||||
* 8. egg_hatching — bright light + baby reveal (auto-advance)
|
||||
* 9. complete — terminal, marks tour done
|
||||
*
|
||||
* The order here matches the intended flow. To reorder steps,
|
||||
* change FIRST_HATCH_TOUR_STEPS (the array), not this type.
|
||||
*/
|
||||
export type FirstHatchTourStepId =
|
||||
| 'idle'
|
||||
| 'show_hatch_card'
|
||||
| 'egg_glowing_waiting_click'
|
||||
| 'egg_crack_stage_1'
|
||||
| 'egg_crack_stage_2'
|
||||
| 'egg_crack_stage_3'
|
||||
| 'egg_opening'
|
||||
| 'egg_hatching'
|
||||
| 'complete';
|
||||
|
||||
/**
|
||||
* Ordered step definitions for the first hatch tour.
|
||||
*
|
||||
* To add / remove / reorder steps, edit this array.
|
||||
* The tour state machine walks through these in order.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_STEPS: TourStepDef<FirstHatchTourStepId>[] = [
|
||||
{ id: 'idle' },
|
||||
{ id: 'show_hatch_card' },
|
||||
{ id: 'egg_glowing_waiting_click' },
|
||||
{ id: 'egg_crack_stage_1' },
|
||||
{ id: 'egg_crack_stage_2' },
|
||||
{ id: 'egg_crack_stage_3' },
|
||||
{ id: 'egg_opening', autoAdvance: true },
|
||||
{ id: 'egg_hatching', autoAdvance: true },
|
||||
{ id: 'complete' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Persisted state shape for the first hatch tour.
|
||||
*/
|
||||
export type FirstHatchTourPersistedState = TourPersistedState<FirstHatchTourStepId>;
|
||||
|
||||
/**
|
||||
* Default persisted state for a brand-new first hatch tour.
|
||||
*/
|
||||
export const FIRST_HATCH_TOUR_DEFAULT_STATE: FirstHatchTourPersistedState = {
|
||||
currentStepId: null,
|
||||
completed: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
+102
-178
@@ -1,50 +1,31 @@
|
||||
/**
|
||||
* BlobbiPhotoModal - Modal for taking and sharing Blobbi photos
|
||||
* BlobbiPhotoModal - Fullscreen photo overlay
|
||||
*
|
||||
* Features:
|
||||
* - Polaroid-style preview of the Blobbi
|
||||
* - Download as PNG
|
||||
* - Post to Nostr with Blossom upload
|
||||
*
|
||||
* Uses html-to-image for DOM-to-PNG conversion.
|
||||
* Simple blurred overlay with the polaroid photo centered,
|
||||
* and download/share buttons below. Tap outside to close.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { Download, Send, Loader2, Camera } from 'lucide-react';
|
||||
import { Download, Share2, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BlobbiPolaroidCard } from './BlobbiPolaroidCard';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { trackDailyMissionProgress } from '@/blobbi/actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export interface BlobbiPhotoModalProps {
|
||||
/** Whether the modal is open */
|
||||
open: boolean;
|
||||
/** Callback when the modal should close */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The Blobbi companion to photograph */
|
||||
companion: BlobbiCompanion;
|
||||
}
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a data URL to a File object
|
||||
*/
|
||||
function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png';
|
||||
@@ -57,218 +38,161 @@ function dataUrlToFile(dataUrl: string, filename: string): File {
|
||||
return new File([u8arr], filename, { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download in the browser
|
||||
*/
|
||||
function downloadFile(dataUrl: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiPhotoModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
}: BlobbiPhotoModalProps) {
|
||||
const polaroidRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
/**
|
||||
* Generate PNG from the polaroid card
|
||||
*/
|
||||
const generateImage = useCallback(async (): Promise<string | null> => {
|
||||
if (!polaroidRef.current) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Could not capture the photo. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!polaroidRef.current) return null;
|
||||
try {
|
||||
// Use html-to-image with high quality settings
|
||||
const dataUrl = await toPng(polaroidRef.current, {
|
||||
return await toPng(polaroidRef.current, {
|
||||
quality: 1.0,
|
||||
pixelRatio: 2, // 2x for retina displays
|
||||
pixelRatio: 2,
|
||||
cacheBust: true,
|
||||
// Skip external fonts that might fail to load
|
||||
skipFonts: true,
|
||||
});
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to generate image:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to generate the photo. Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to generate image:', error);
|
||||
toast({ variant: 'destructive', title: 'Error', description: 'Failed to capture photo.' });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle download action
|
||||
*/
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsGenerating(true);
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const dataUrl = await generateImage();
|
||||
if (dataUrl) {
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`;
|
||||
downloadFile(dataUrl, filename);
|
||||
toast({
|
||||
title: 'Photo saved!',
|
||||
description: 'Your Blobbi photo has been downloaded.',
|
||||
});
|
||||
if (!dataUrl) return;
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-photo.png`;
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// On native, use the download utility which handles share sheet
|
||||
const blob = dataUrlToFile(dataUrl, filename);
|
||||
const url = URL.createObjectURL(blob);
|
||||
await openUrl(url);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
}
|
||||
|
||||
toast({ title: 'Photo saved!' });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [generateImage, companion.name]);
|
||||
|
||||
/**
|
||||
* Handle post action - upload to Blossom and create Nostr post
|
||||
*/
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to post your Blobbi photo.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPosting(true);
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsSharing(true);
|
||||
try {
|
||||
// Generate the image
|
||||
const dataUrl = await generateImage();
|
||||
if (!dataUrl) {
|
||||
return;
|
||||
}
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Convert to File for upload
|
||||
const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`;
|
||||
const file = dataUrlToFile(dataUrl, filename);
|
||||
|
||||
// Upload to Blossom - returns NIP-94 compatible tags
|
||||
const tags = await uploadFile(file);
|
||||
|
||||
// Extract URL from the 'url' tag (NIP-94 format)
|
||||
// The upload hook returns tags like [['url', '...'], ['m', '...'], ['x', '...'], ...]
|
||||
const urlTag = tags.find((tag) => tag[0] === 'url');
|
||||
if (!urlTag || !urlTag[1]) {
|
||||
throw new Error('Upload succeeded but no URL was returned');
|
||||
}
|
||||
if (!urlTag?.[1]) throw new Error('Upload succeeded but no URL returned');
|
||||
const url = urlTag[1];
|
||||
|
||||
// Build imeta tag from all NIP-94 tags
|
||||
// Format: ['imeta', 'url https://...', 'm image/png', 'x abc123', ...]
|
||||
const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`);
|
||||
|
||||
// Create the post content
|
||||
const content = `${companion.name} ${url}`;
|
||||
|
||||
// Publish kind 1 event
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content,
|
||||
content: `${companion.name} ${url}`,
|
||||
tags: [['imeta', ...imetaFields]],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Posted!',
|
||||
description: 'Your Blobbi photo has been shared.',
|
||||
});
|
||||
|
||||
// Track daily mission progress for photo action
|
||||
toast({ title: 'Posted!', description: 'Your Blobbi photo has been shared.' });
|
||||
trackDailyMissionProgress('take_photo', 1, user.pubkey);
|
||||
|
||||
// Close the modal after successful post
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('[BlobbiPhotoModal] Failed to post:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to post',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
console.error('[BlobbiPhoto] Failed to share:', error);
|
||||
toast({ variant: 'destructive', title: 'Failed to post', description: error instanceof Error ? error.message : 'Please try again.' });
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
setIsSharing(false);
|
||||
}
|
||||
}, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]);
|
||||
|
||||
const isProcessing = isGenerating || isPosting;
|
||||
if (!open) return null;
|
||||
|
||||
const isProcessing = isDownloading || isSharing;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Camera className="size-5" />
|
||||
Take a Photo
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Capture a polaroid-style photo of {companion.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||
{/* Backdrop — tap to close */}
|
||||
<div
|
||||
className="absolute inset-0 bg-background/60 backdrop-blur-sm"
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{/* Polaroid preview - centered */}
|
||||
<div className="flex justify-center py-4">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
{/* Close button — top-right of the container */}
|
||||
<button
|
||||
onClick={() => !isProcessing && onOpenChange(false)}
|
||||
className="absolute top-3 right-3 z-10 p-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
{/* Polaroid card */}
|
||||
<div className="relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<BlobbiPolaroidCard
|
||||
ref={polaroidRef}
|
||||
companion={companion}
|
||||
showStage
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="relative z-10 flex items-center gap-6 mt-8">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-sky-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #0ea5e9 25%, transparent), color-mix(in srgb, #0ea5e9 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isDownloading ? <Loader2 className="size-6 animate-spin" /> : <Download className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Save</span>
|
||||
</button>
|
||||
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4 mr-2" />
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 transition-all duration-200',
|
||||
'hover:scale-110 active:scale-95',
|
||||
isProcessing && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePost}
|
||||
disabled={isProcessing || !user}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPosting ? (
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4 mr-2" />
|
||||
)}
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login hint if not logged in */}
|
||||
{!user && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Log in to post your Blobbi photo
|
||||
</p>
|
||||
<div className="size-14 rounded-full flex items-center justify-center text-violet-500" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, #8b5cf6 25%, transparent), color-mix(in srgb, #8b5cf6 10%, transparent) 70%)',
|
||||
}}>
|
||||
{isSharing ? <Loader2 className="size-6 animate-spin" /> : <Share2 className="size-6" />}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Post</span>
|
||||
</button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function blobbiCompanionToBlobbi(companion: BlobbiCompanion): Blobbi {
|
||||
size: companion.visualTraits.size,
|
||||
// Metadata
|
||||
seed: companion.seed,
|
||||
tags: companion.allTags,
|
||||
tags: companion.allTags ?? [],
|
||||
// Adult-specific data (for adult form resolution)
|
||||
adult: companion.adultType ? { evolutionForm: companion.adultType } : undefined,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Play, Pause, Music, ListMusic, Podcast, Clock } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
|
||||
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
|
||||
/**
|
||||
* Auto-minimizes the audio player when the user navigates to a different page.
|
||||
|
||||
@@ -3,39 +3,7 @@ import { Award } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
|
||||
/** Parsed NIP-58 badge definition data. */
|
||||
export interface BadgeData {
|
||||
identifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageDimensions?: string;
|
||||
thumbs: Array<{ url: string; dimensions?: string }>;
|
||||
}
|
||||
|
||||
/** Parse a kind 30009 badge definition event into structured data. */
|
||||
export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
|
||||
if (event.kind !== 30009) return null;
|
||||
|
||||
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (!identifier) return null;
|
||||
|
||||
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
|
||||
const description = event.tags.find(([n]) => n === 'description')?.[1];
|
||||
const imageTag = event.tags.find(([n]) => n === 'image');
|
||||
const image = imageTag?.[1];
|
||||
const imageDimensions = imageTag?.[2];
|
||||
|
||||
const thumbs: Array<{ url: string; dimensions?: string }> = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'thumb' && tag[1]) {
|
||||
thumbs.push({ url: tag[1], dimensions: tag[2] });
|
||||
}
|
||||
}
|
||||
|
||||
return { identifier, name, description, image, imageDimensions, thumbs };
|
||||
}
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
|
||||
interface BadgeContentProps {
|
||||
event: NostrEvent;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { parseBadgeDefinition } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { BadgeData } from '@/components/BadgeContent';
|
||||
import type { BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeDisplayItem {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Award } from 'lucide-react';
|
||||
|
||||
import type { BadgeData } from '@/components/BadgeContent';
|
||||
import type { BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
|
||||
import type { CoreThemeColors } from '@/themes';
|
||||
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
|
||||
@@ -12,12 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
export function getColors(tags: string[][]): string[] {
|
||||
return tags
|
||||
.filter(([n]) => n === 'c')
|
||||
.map(([, v]) => v)
|
||||
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
|
||||
}
|
||||
|
||||
|
||||
/** Compute a best-fit grid: cols × rows for n items. */
|
||||
function gridDimensions(n: number): { cols: number; rows: number } {
|
||||
@@ -193,82 +187,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Palette → theme mapping ─────────────────────────────
|
||||
|
||||
function hexContrast(hex1: string, hex2: string): number {
|
||||
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
|
||||
}
|
||||
|
||||
function hexSaturation(hex: string): number {
|
||||
return rgbToHsl(...hexToRgb(hex)).s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
|
||||
* contrast against `bgHsl`. Steps toward white or black depending on which
|
||||
* direction gives better contrast. Returns the adjusted HSL string.
|
||||
*/
|
||||
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
|
||||
const bg = parseHsl(bgHsl);
|
||||
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
|
||||
const { h, s, l } = parseHsl(hsl);
|
||||
|
||||
// Decide direction: go lighter if bg is dark, darker if bg is light
|
||||
const goLighter = bgLum < 0.18;
|
||||
let current = l;
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
current = goLighter
|
||||
? Math.min(100, current + 2)
|
||||
: Math.max(0, current - 2);
|
||||
const rgb = hslToRgb(h, s, current);
|
||||
const lum = getLuminance(...rgb);
|
||||
const lighter = Math.max(bgLum, lum);
|
||||
const darker = Math.min(bgLum, lum);
|
||||
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
|
||||
}
|
||||
|
||||
return formatHsl(h, s, current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map palette hex colors to CoreThemeColors with guaranteed readability:
|
||||
* 1. background = darkest color
|
||||
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
|
||||
* 3. primary = most saturated remaining color; if contrast < 3:1 against
|
||||
* background, adjust its lightness until it passes
|
||||
*/
|
||||
export function paletteToTheme(colors: string[]): CoreThemeColors {
|
||||
if (colors.length === 0) {
|
||||
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
|
||||
}
|
||||
|
||||
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
|
||||
const bgHex = sorted[0];
|
||||
const bgHsl = hexToHslString(bgHex);
|
||||
|
||||
// Text: lightest palette color; override with white/black if contrast is too low
|
||||
const textHex = sorted[sorted.length - 1];
|
||||
let textHsl = hexToHslString(textHex);
|
||||
if (hexContrast(textHex, bgHex) < 4.5) {
|
||||
// Pick white or black — whichever contrasts better
|
||||
const whiteContrast = hexContrast('#ffffff', bgHex);
|
||||
const blackContrast = hexContrast('#000000', bgHex);
|
||||
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
|
||||
}
|
||||
|
||||
// Primary: most saturated of remaining colors; nudge lightness if needed
|
||||
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
|
||||
const pool = rest.length > 0 ? rest : [textHex];
|
||||
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
|
||||
let primaryHsl = hexToHslString(primaryHex);
|
||||
if (hexContrast(primaryHex, bgHex) < 3) {
|
||||
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
|
||||
}
|
||||
|
||||
return { background: bgHsl, text: textHsl, primary: primaryHsl };
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────
|
||||
|
||||
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
|
||||
|
||||
@@ -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',
|
||||
@@ -142,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,
|
||||
@@ -488,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';
|
||||
@@ -526,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 (
|
||||
@@ -586,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 ?? '';
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
@@ -26,7 +26,7 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
|
||||
// ── data hook ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useEventComments(event: NostrEvent | undefined) {
|
||||
function useEventComments(event: NostrEvent | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const aTag = event
|
||||
|
||||
@@ -20,6 +20,7 @@ import { GifPicker } from '@/components/GifPicker';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
@@ -1085,6 +1086,7 @@ export function ComposeBox({
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPointerDown={expand}
|
||||
onFocus={expand}
|
||||
onPaste={handlePaste}
|
||||
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
|
||||
@@ -1266,7 +1268,7 @@ export function ComposeBox({
|
||||
identifier: quotedEvent.tags.find(([name]) => name === 'd')?.[1] ?? '',
|
||||
}} />
|
||||
) : (
|
||||
<EmbeddedNote eventId={quotedEvent.id} />
|
||||
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1487,11 +1489,11 @@ export function ComposeBox({
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import {
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe,
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
|
||||
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -24,7 +25,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useMuteList, type MuteListItem } from '@/hooks/useMuteList';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { FeedEditModal } from '@/components/FeedEditModal';
|
||||
import { buildKindOptions } from '@/components/SavedFeedFiltersEditor';
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
@@ -556,6 +557,183 @@ function FeedTabsSection() {
|
||||
|
||||
{/* Saved Feeds */}
|
||||
<SavedFeedsSection />
|
||||
|
||||
{/* Interests (hashtag & geotag tabs) */}
|
||||
<InterestsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Interests Section ───────────────────────────────────────────────────────
|
||||
|
||||
function InterestsSection() {
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t');
|
||||
const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g');
|
||||
const [newHashtag, setNewHashtag] = useState('');
|
||||
const [newGeotag, setNewGeotag] = useState('');
|
||||
|
||||
const isLoading = isLoadingHashtags || isLoadingGeotags;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleRemoveHashtag = async (tag: string) => {
|
||||
await removeHashtag.mutateAsync(tag);
|
||||
toast({ title: `#${tag} removed from feed tabs` });
|
||||
};
|
||||
|
||||
const handleRemoveGeotag = async (tag: string) => {
|
||||
await removeGeotag.mutateAsync(tag);
|
||||
toast({ title: `${tag} removed from feed tabs` });
|
||||
};
|
||||
|
||||
const handleAddHashtag = async () => {
|
||||
const tag = newHashtag.trim().toLowerCase().replace(/^#/, '');
|
||||
if (!tag) return;
|
||||
if (hashtags.includes(tag)) {
|
||||
toast({ title: `#${tag} is already followed`, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
await addHashtag.mutateAsync(tag);
|
||||
setNewHashtag('');
|
||||
toast({ title: `#${tag} added to feed tabs` });
|
||||
};
|
||||
|
||||
const handleAddGeotag = async () => {
|
||||
const tag = newGeotag.trim().toLowerCase();
|
||||
if (!tag) return;
|
||||
if (geotags.includes(tag)) {
|
||||
toast({ title: `${tag} is already followed`, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
await addGeotag.mutateAsync(tag);
|
||||
setNewGeotag('');
|
||||
toast({ title: `${tag} added to feed tabs` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-4 space-y-4 border-t border-border">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Interest Tabs</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Hashtags and locations you follow appear as tabs on the home feed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hashtags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Hashtags</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="ditto"
|
||||
value={newHashtag}
|
||||
onChange={(e) => setNewHashtag(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
|
||||
className="h-9"
|
||||
disabled={addHashtag.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddHashtag}
|
||||
disabled={addHashtag.isPending || !newHashtag.trim()}
|
||||
size="sm"
|
||||
className="h-9"
|
||||
>
|
||||
{addHashtag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : hashtags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No followed hashtags yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{hashtags.map((tag) => (
|
||||
<div
|
||||
key={`hashtag:${tag}`}
|
||||
className="rounded-lg border border-border/50 bg-secondary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-2 px-2.5">
|
||||
<Hash className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveHashtag(tag)}
|
||||
disabled={removeHashtag.isPending}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
|
||||
aria-label={`Remove #${tag}`}
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Geotags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Locations</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="geohash (e.g. u4pru)"
|
||||
value={newGeotag}
|
||||
onChange={(e) => setNewGeotag(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }}
|
||||
className="h-9"
|
||||
disabled={addGeotag.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddGeotag}
|
||||
disabled={addGeotag.isPending || !newGeotag.trim()}
|
||||
size="sm"
|
||||
className="h-9"
|
||||
>
|
||||
{addGeotag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : geotags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No followed locations yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{geotags.map((tag) => (
|
||||
<div
|
||||
key={`geotag:${tag}`}
|
||||
className="rounded-lg border border-border/50 bg-secondary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-2 px-2.5">
|
||||
<MapPin className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveGeotag(tag)}
|
||||
disabled={removeGeotag.isPending}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
|
||||
import { isCustomEmoji, getCustomEmojiUrl, buildEmojiMap, type ResolvedEmoji } from '@/lib/customEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Threshold at or below which we apply nearest-neighbor scaling. */
|
||||
const PIXEL_ART_MAX = 16;
|
||||
|
||||
interface CustomEmojiImgProps {
|
||||
/** The shortcode name (without colons). */
|
||||
name: string;
|
||||
@@ -14,16 +17,30 @@ interface CustomEmojiImgProps {
|
||||
|
||||
/**
|
||||
* Renders a single custom emoji as an inline image.
|
||||
*
|
||||
* If the image's natural dimensions are 16x16 or smaller, nearest-neighbor
|
||||
* (`image-rendering: pixelated`) scaling is applied to preserve crisp pixels.
|
||||
*/
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
|
||||
const [pixelated, setPixelated] = useState(false);
|
||||
|
||||
const handleLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalWidth <= PIXEL_ART_MAX && img.naturalHeight <= PIXEL_ART_MAX) {
|
||||
setPixelated(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={`:${name}:`}
|
||||
title={`:${name}:`}
|
||||
className={className}
|
||||
style={pixelated ? { imageRendering: 'pixelated' } : undefined}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
|
||||
@@ -25,7 +26,7 @@ export interface EmojiPackData {
|
||||
}
|
||||
|
||||
/** Parse a kind 30030 emoji pack event into structured data. */
|
||||
export function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
|
||||
function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
|
||||
if (event.kind !== 30030) return null;
|
||||
|
||||
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
@@ -172,12 +173,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
className="group relative"
|
||||
title={`:${emoji.shortcode}:`}
|
||||
>
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={`:${emoji.shortcode}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain rounded transition-transform group-hover:scale-125"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { SortableList, SortableItem } from '@/components/SortableList';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
@@ -331,14 +332,15 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
|
||||
// For edit mode, fetch fresh event to preserve any tags we don't manage
|
||||
let preservedTags: string[][] = [];
|
||||
let prev: NostrEvent | null = null;
|
||||
if (isEditMode) {
|
||||
const fresh = await fetchFreshEvent(nostr, {
|
||||
prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [30030],
|
||||
authors: [user.pubkey],
|
||||
'#d': [resolvedId],
|
||||
});
|
||||
if (fresh) {
|
||||
preservedTags = fresh.tags.filter(
|
||||
if (prev) {
|
||||
preservedTags = prev.tags.filter(
|
||||
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
|
||||
);
|
||||
}
|
||||
@@ -356,7 +358,8 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
kind: 30030,
|
||||
content: '',
|
||||
tags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
// Clean up blob URLs
|
||||
for (const e of emojis) {
|
||||
@@ -506,11 +509,10 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
>
|
||||
<div className="flex items-center gap-2 pr-2 py-1.5">
|
||||
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="size-8 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
|
||||
|
||||
@@ -375,11 +376,10 @@ export function EmojiShortcodeAutocomplete({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{emoji.customUrl ? (
|
||||
<img
|
||||
src={emoji.customUrl}
|
||||
alt={`:${emoji.name}:`}
|
||||
<CustomEmojiImg
|
||||
name={emoji.name}
|
||||
url={emoji.customUrl}
|
||||
className="size-5 object-contain shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
|
||||
|
||||
@@ -18,14 +18,13 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
|
||||
import {
|
||||
buildKindOptions,
|
||||
MultiKindPicker,
|
||||
AuthorChip,
|
||||
AuthorFilterDropdown,
|
||||
ScopeToggle,
|
||||
ListPackPicker,
|
||||
parseSelectedKinds,
|
||||
} from '@/components/SavedFeedFiltersEditor';
|
||||
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import QRCode from 'qrcode';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
|
||||
|
||||
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
|
||||
const MIN_QR_CONTRAST = 3;
|
||||
|
||||
/** Saturation threshold (%) above which a color is considered "colorful". */
|
||||
const COLORFUL_SAT_MIN = 15;
|
||||
/** Lightness range within which a color appears visually colorful. */
|
||||
const COLORFUL_L_MIN = 20;
|
||||
const COLORFUL_L_MAX = 80;
|
||||
|
||||
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
|
||||
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
|
||||
if (!raw) return null;
|
||||
const { h, s, l } = parseHsl(raw);
|
||||
if ([h, s, l].some(isNaN)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function darkenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l > 0 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.max(0, l - 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
|
||||
* Returns the adjusted hex color.
|
||||
*/
|
||||
function lightenToContrast(
|
||||
hsl: { h: number; s: number; l: number },
|
||||
refRgb: [number, number, number],
|
||||
): string {
|
||||
let l = hsl.l;
|
||||
let rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
let ratio = getContrastRatio(rgb, refRgb);
|
||||
while (l < 100 && ratio < MIN_QR_CONTRAST) {
|
||||
l = Math.min(100, l + 2);
|
||||
rgb = hslToRgb(hsl.h, hsl.s, l);
|
||||
ratio = getContrastRatio(rgb, refRgb);
|
||||
}
|
||||
return rgbToHex(...rgb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best module color from primary and foreground.
|
||||
*
|
||||
* Strongly prefers primary since it carries the theme's brand identity.
|
||||
* Only picks foreground if it is colorful (saturation > threshold) AND
|
||||
* has significantly better contrast (> 1.5x) against the QR background.
|
||||
*/
|
||||
function pickModuleColor(
|
||||
primary: { h: number; s: number; l: number },
|
||||
foreground: { h: number; s: number; l: number } | null,
|
||||
bgRgb: [number, number, number],
|
||||
): { h: number; s: number; l: number } {
|
||||
const fgIsColorful = foreground
|
||||
&& foreground.s >= COLORFUL_SAT_MIN
|
||||
&& foreground.l >= COLORFUL_L_MIN
|
||||
&& foreground.l <= COLORFUL_L_MAX;
|
||||
|
||||
if (!fgIsColorful) return primary;
|
||||
|
||||
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
|
||||
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
|
||||
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
|
||||
const fgContrast = getContrastRatio(fgRgb, bgRgb);
|
||||
|
||||
// Foreground must be significantly better to override primary
|
||||
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive QR module and background hex colors from the active theme.
|
||||
*
|
||||
* Light themes: white background, best themed color as modules (darkened if needed).
|
||||
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
|
||||
*
|
||||
* "Best themed color" is --primary by default. If --foreground is colorful
|
||||
* (saturation > 15%) and offers better contrast, it wins instead.
|
||||
*/
|
||||
function getThemedQRColors(): { dark: string; light: string } {
|
||||
const primary = readCssHsl('--primary');
|
||||
const foreground = readCssHsl('--foreground');
|
||||
const background = readCssHsl('--background');
|
||||
|
||||
if (!primary) return { dark: '#000000', light: '#ffffff' };
|
||||
|
||||
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
|
||||
|
||||
if (!isDark) {
|
||||
const white: [number, number, number] = [255, 255, 255];
|
||||
const module = pickModuleColor(primary, foreground, white);
|
||||
return { dark: darkenToContrast(module, white), light: '#ffffff' };
|
||||
}
|
||||
|
||||
if (!background) return { dark: '#ffffff', light: '#000000' };
|
||||
const bgRgb = hslToRgb(background.h, background.s, background.l);
|
||||
const module = pickModuleColor(primary, foreground, bgRgb);
|
||||
return {
|
||||
dark: lightenToContrast(module, bgRgb),
|
||||
light: rgbToHex(...bgRgb),
|
||||
};
|
||||
}
|
||||
|
||||
interface FollowQRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey ?? '');
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = user ? metadata?.name || genUserName(user.pubkey) : '';
|
||||
|
||||
const npub = user ? nip19.npubEncode(user.pubkey) : '';
|
||||
const followUrl = npub ? `${window.location.origin}/follow/${npub}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!followUrl || !open) return;
|
||||
|
||||
const { dark, light } = getThemedQRColors();
|
||||
|
||||
QRCode.toDataURL(followUrl, {
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark, light },
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
.then(setQrDataUrl)
|
||||
.catch(console.error);
|
||||
}, [followUrl, open]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(followUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm p-6 flex flex-col items-center gap-5 rounded-2xl">
|
||||
<DialogTitle className="sr-only">Share follow link</DialogTitle>
|
||||
|
||||
{/* Avatar + name */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Avatar shape={getAvatarShape(metadata)} className="size-16 ring-2 ring-secondary">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="text-xl font-semibold">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Scan to follow <span className="text-foreground font-medium">{displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="Follow QR code"
|
||||
className="w-full rounded-xl border border-border"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square rounded-xl border border-border bg-muted animate-pulse" />
|
||||
)}
|
||||
|
||||
{/* Copy link */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-primary flex-shrink-0" />
|
||||
: <Copy className="size-3.5 flex-shrink-0" />}
|
||||
<span className="truncate max-w-64">{followUrl}</span>
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseBadgeDefinition } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAwardBadge } from '@/hooks/useAwardBadge';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
@@ -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}>
|
||||
@@ -643,7 +671,7 @@ function ProfileStep({
|
||||
);
|
||||
if (validFields.length > 0)
|
||||
data.fields = validFields.map((f) => [f.label, f.value]);
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(data) });
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(data), tags: [] });
|
||||
queryClient.invalidateQueries({ queryKey: ["logins"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
|
||||
} catch {
|
||||
@@ -921,15 +949,15 @@ function FollowsStep({
|
||||
})
|
||||
.catch((): NostrEvent[] => []);
|
||||
|
||||
const latestEvent =
|
||||
const prev =
|
||||
followEvents.length > 0
|
||||
? followEvents.reduce((latest, current) =>
|
||||
current.created_at > latest.created_at ? current : latest,
|
||||
)
|
||||
: null;
|
||||
|
||||
const existingFollows = latestEvent
|
||||
? latestEvent.tags
|
||||
const existingFollows = prev
|
||||
? prev.tags
|
||||
.filter(([name]) => name === "p")
|
||||
.map(([, pk]) => pk)
|
||||
: [];
|
||||
@@ -938,8 +966,9 @@ function FollowsStep({
|
||||
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: latestEvent?.content ?? "",
|
||||
content: prev?.content ?? "",
|
||||
tags: allFollows.map((pk) => ["p", pk]),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
setFollowedPacks((prev) => new Set([...prev, packId]));
|
||||
|
||||
@@ -155,16 +155,19 @@ function QuotesTab({ quotes }: { quotes: QuoteEntry[] }) {
|
||||
|
||||
/* ──── Reactions Tab ──── */
|
||||
function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
|
||||
// Group reactions by emoji
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, ReactionEntry[]>();
|
||||
// Summary of unique emojis with counts, sorted by popularity
|
||||
const emojiSummary = useMemo(() => {
|
||||
const counts = new Map<string, { count: number; url?: string }>();
|
||||
for (const r of reactions) {
|
||||
const key = r.emoji;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(r);
|
||||
const existing = counts.get(r.emoji);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
counts.set(r.emoji, { count: 1, url: r.emojiUrl });
|
||||
}
|
||||
}
|
||||
// Sort groups by count (most popular first)
|
||||
return Array.from(groups.entries()).sort((a, b) => b[1].length - a[1].length);
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
}, [reactions]);
|
||||
|
||||
if (reactions.length === 0) {
|
||||
@@ -173,32 +176,31 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{grouped.map(([emoji, entries]) => {
|
||||
// Check if this is a custom emoji — use the URL from the first entry
|
||||
const firstEntry = entries[0];
|
||||
const customUrl = firstEntry?.emojiUrl;
|
||||
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
|
||||
{/* Emoji summary bar */}
|
||||
{emojiSummary.length > 1 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-secondary/30 border-b border-border flex-wrap">
|
||||
{emojiSummary.map(([emoji, { count, url }]) => {
|
||||
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
|
||||
return (
|
||||
<span key={emoji} className="inline-flex items-center gap-1 text-sm">
|
||||
{url && customName ? (
|
||||
<CustomEmojiImg name={customName} url={url} className="inline-block h-5 w-5 object-contain" />
|
||||
) : (
|
||||
<span>{emoji}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-medium tabular-nums">{count}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div key={emoji}>
|
||||
{/* 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 object-contain" />
|
||||
) : (
|
||||
<span className="text-lg">{emoji}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-medium">{entries.length}</span>
|
||||
</div>
|
||||
{/* Users who reacted with this emoji — each row links to the reaction event */}
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry, i) => (
|
||||
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Flat list — each row shows the emoji badge inline */}
|
||||
<div className="divide-y divide-border">
|
||||
{reactions.map((entry, i) => (
|
||||
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -275,6 +277,7 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(entry.pubkey);
|
||||
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
|
||||
const customName = isCustomEmoji(entry.emoji) ? entry.emoji.slice(1, -1) : undefined;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -302,6 +305,15 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
<span className="text-xs text-muted-foreground">{timeAgo(entry.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Reaction emoji badge */}
|
||||
<div className="flex items-center justify-center shrink-0 bg-secondary/60 rounded-full size-8">
|
||||
{entry.emojiUrl && customName ? (
|
||||
<CustomEmojiImg name={customName} url={entry.emojiUrl} className="inline-block h-5 w-5 object-contain" />
|
||||
) : (
|
||||
<span className="text-base leading-none">{entry.emoji}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
UserPlus, LogOut,
|
||||
Loader2,
|
||||
Loader2, QrCode,
|
||||
} from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -15,6 +15,7 @@ import { SidebarNavList } from '@/components/SidebarNavItem';
|
||||
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
|
||||
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||
@@ -55,6 +56,7 @@ export function LeftSidebar() {
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
@@ -293,6 +295,10 @@ export function LeftSidebar() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-1">
|
||||
<button onClick={() => { setAccountPopoverOpen(false); setFollowQROpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
|
||||
<QrCode className="size-4 text-muted-foreground" />
|
||||
<span>Share profile</span>
|
||||
</button>
|
||||
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
|
||||
<UserPlus className="size-4 text-muted-foreground" />
|
||||
<span>Add another account</span>
|
||||
@@ -308,6 +314,7 @@ export function LeftSidebar() {
|
||||
)}
|
||||
|
||||
<LoginDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} onLogin={() => setLoginDialogOpen(false)} onSignupClick={startSignup} />
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,70 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Info, BookOpen, Shield, Code, ScrollText } from 'lucide-react';
|
||||
|
||||
interface LinkFooterProps {
|
||||
/** Optional callback fired when an internal (React Router) link is clicked. */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
const chipClass =
|
||||
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors';
|
||||
const iconClass = 'size-3 shrink-0';
|
||||
|
||||
/** Shared footer links used in both sidebars. */
|
||||
export function LinkFooter({ onNavigate }: LinkFooterProps) {
|
||||
return (
|
||||
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<footer className="mt-auto pt-3 pb-3 -mx-1 sidebar:bg-background/85 sidebar:rounded-xl sidebar:p-3">
|
||||
<nav className="flex items-center justify-center gap-0.5 flex-wrap" aria-label="Footer links">
|
||||
<a
|
||||
href="https://about.ditto.pub"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Info className={iconClass} />
|
||||
About
|
||||
</a>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://about.ditto.pub/docs/"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpen className={iconClass} />
|
||||
Docs
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
|
||||
<Link to="/privacy" className={chipClass} onClick={onNavigate}>
|
||||
<Shield className={iconClass} />
|
||||
Privacy
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://gitlab.com/soapbox-pub/ditto"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Code className={iconClass} />
|
||||
Source
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
|
||||
<Link to="/changelog" className={chipClass} onClick={onNavigate}>
|
||||
<ScrollText className={iconClass} />
|
||||
Changelog
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
<a
|
||||
href="https://shakespeare.diy/clone?url=https%3A%2F%2Fgitlab.com%2Fsoapbox-pub%2Fditto.git"
|
||||
className="text-primary hover:underline"
|
||||
className={chipClass}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="text-xs leading-none" aria-hidden>🎭</span>
|
||||
Edit with Shakespeare
|
||||
</a>
|
||||
</p>
|
||||
</nav>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,48 +19,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { getContentWarning } from '@/lib/contentWarning';
|
||||
|
||||
// ── Media type detection ──────────────────────────────────────────────────────
|
||||
|
||||
export type MediaType = 'image' | 'video' | 'audio';
|
||||
|
||||
/** Event kinds that are inherently video content (vines, horizontal video, vertical video). */
|
||||
const VIDEO_KINDS = new Set([34236, 21, 22]);
|
||||
/** Event kinds that are inherently audio content (music tracks, podcast episodes/trailers). */
|
||||
const AUDIO_KINDS = new Set([36787, 34139, 30054, 30055, 1222]);
|
||||
|
||||
function detectType(url: string, mime?: string, eventKind?: number): MediaType {
|
||||
if (mime) {
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
}
|
||||
if (/\.(mp4|webm|mov|qt|m3u8)(\?.*)?$/i.test(url)) return 'video';
|
||||
if (/\.(mp3|ogg|flac|wav|aac|opus)(\?.*)?$/i.test(url)) return 'audio';
|
||||
// Fall back to event kind for extensionless URLs (e.g. Blossom content-addressed URLs)
|
||||
if (eventKind !== undefined) {
|
||||
if (VIDEO_KINDS.has(eventKind)) return 'video';
|
||||
if (AUDIO_KINDS.has(eventKind)) return 'audio';
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// ── Aspect ratio helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Default aspect ratio when dim tag is missing or unparseable. */
|
||||
const DEFAULT_ASPECT_RATIO = 1;
|
||||
|
||||
/** Parse a dim string like "1280x720" into a width/height aspect ratio number. */
|
||||
export function parseDimToAspectRatio(dim?: string): number {
|
||||
if (!dim) return DEFAULT_ASPECT_RATIO;
|
||||
const match = dim.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) return DEFAULT_ASPECT_RATIO;
|
||||
const w = parseInt(match[1], 10);
|
||||
const h = parseInt(match[2], 10);
|
||||
if (!w || !h) return DEFAULT_ASPECT_RATIO;
|
||||
return w / h;
|
||||
}
|
||||
import { parseDimToAspectRatio, eventToMediaItem, type MediaType, type MediaItem } from '@/lib/mediaUtils';
|
||||
|
||||
/** A row of items in the justified layout. */
|
||||
interface JustifiedRow<T> {
|
||||
@@ -127,82 +86,6 @@ function justifiedLayout<T>(
|
||||
return { rows, lastRowIncomplete: false };
|
||||
}
|
||||
|
||||
// ── Media extraction ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface MediaItem {
|
||||
url: string;
|
||||
type: MediaType;
|
||||
blurhash?: string;
|
||||
dim?: string;
|
||||
alt?: string;
|
||||
mime?: string;
|
||||
allUrls: string[];
|
||||
allTypes: MediaType[];
|
||||
allDims: (string | undefined)[];
|
||||
event: NostrEvent;
|
||||
hasMultiple: boolean;
|
||||
/** NIP-36 content warning reason, or empty string if flagged with no reason, or undefined if clean. */
|
||||
contentWarning?: string;
|
||||
}
|
||||
|
||||
function parseImeta(tags: string[][]): { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] {
|
||||
const results: { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
const parts: Record<string, string> = {};
|
||||
for (let i = 1; i < tag.length; i++) {
|
||||
const sp = tag[i].indexOf(' ');
|
||||
if (sp !== -1) parts[tag[i].slice(0, sp)] = tag[i].slice(sp + 1);
|
||||
}
|
||||
if (parts.url) results.push({ url: parts.url, blurhash: parts.blurhash, dim: parts.dim, alt: parts.alt, mime: parts.m });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractMediaUrls(content: string): string[] {
|
||||
return content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|qt|mp3|ogg|flac|wav|aac|opus)(\?[^\s]*)?/gi) ?? [];
|
||||
}
|
||||
|
||||
export function eventToMediaItem(event: NostrEvent): MediaItem | null {
|
||||
const imeta = parseImeta(event.tags);
|
||||
const cw = getContentWarning(event);
|
||||
if (imeta.length > 0) {
|
||||
const first = imeta[0];
|
||||
const firstType = detectType(first.url, first.mime, event.kind);
|
||||
return {
|
||||
url: first.url,
|
||||
type: firstType,
|
||||
blurhash: first.blurhash,
|
||||
dim: first.dim,
|
||||
alt: first.alt,
|
||||
mime: first.mime,
|
||||
allUrls: imeta.map((e) => e.url),
|
||||
allTypes: imeta.map((e) => detectType(e.url, e.mime, event.kind)),
|
||||
allDims: imeta.map((e) => e.dim),
|
||||
event,
|
||||
hasMultiple: imeta.length > 1,
|
||||
contentWarning: cw,
|
||||
};
|
||||
}
|
||||
if (event.kind === 1) {
|
||||
const urls = extractMediaUrls(event.content);
|
||||
if (urls.length > 0) {
|
||||
const types = urls.map((u) => detectType(u));
|
||||
return {
|
||||
url: urls[0],
|
||||
type: types[0],
|
||||
allUrls: urls,
|
||||
allTypes: types,
|
||||
allDims: urls.map(() => undefined),
|
||||
event,
|
||||
hasMultiple: urls.length > 1,
|
||||
contentWarning: cw,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Flat entry — one per media URL across all events ─────────────────────────
|
||||
|
||||
interface FlatEntry {
|
||||
|
||||
@@ -3,33 +3,6 @@ import { Play, Pause } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
|
||||
/** Audio file extensions used to detect audio URLs. */
|
||||
const AUDIO_EXTENSIONS = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i;
|
||||
|
||||
/** Image file extensions used to detect image URLs. */
|
||||
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i;
|
||||
|
||||
/** Video file extensions used to detect video URLs. */
|
||||
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|qt)(\?.*)?$/i;
|
||||
|
||||
/** Check whether a URL points to an audio file by extension. */
|
||||
export function isAudioUrl(url: string): boolean {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
||||
return AUDIO_EXTENSIONS.test(url);
|
||||
}
|
||||
|
||||
/** Check whether a URL points to an image file by extension. */
|
||||
export function isImageUrl(url: string): boolean {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
||||
return IMAGE_EXTENSIONS.test(url);
|
||||
}
|
||||
|
||||
/** Check whether a URL points to a video file by extension. */
|
||||
export function isVideoUrl(url: string): boolean {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
||||
return VIDEO_EXTENSIONS.test(url);
|
||||
}
|
||||
|
||||
interface MiniAudioPlayerProps {
|
||||
src: string;
|
||||
label?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Pause, SkipBack, SkipForward, Maximize2, X, GripVertical } from 'lucide-react';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const POSITION_KEY = 'audio-minibar-position';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useId, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2 } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
@@ -11,6 +11,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import { FollowQRDialog } from '@/components/FollowQRDialog';
|
||||
import { useOnboarding } from '@/hooks/useOnboarding';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
@@ -60,6 +61,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [accountExpanded, setAccountExpanded] = useState(false);
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const { startSignup } = useOnboarding();
|
||||
const { theme, customTheme, themes } = useTheme();
|
||||
|
||||
@@ -269,6 +271,13 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { handleClose(); setFollowQROpen(true); }}
|
||||
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<QrCode className="size-5 shrink-0" />
|
||||
<span>Share profile</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleClose(); setLoginDialogOpen(true); }}
|
||||
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
|
||||
@@ -318,7 +327,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<div className="px-2 safe-area-bottom">
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,7 +371,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<div className="px-2 safe-area-bottom">
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,6 +385,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
onLogin={() => setLoginDialogOpen(false)}
|
||||
onSignupClick={startSignup}
|
||||
/>
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useEncryptedSettings } from "@/hooks/useEncryptedSettings";
|
||||
import { useEncryptedSettings, setLocalSettingsSync } from "@/hooks/useEncryptedSettings";
|
||||
import { isSyncDone } from "@/hooks/useInitialSync";
|
||||
import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
|
||||
@@ -246,16 +246,13 @@ export function NostrSync() {
|
||||
// Get the remote sync timestamp
|
||||
const remoteSync = encryptedSettings.lastSync || 0;
|
||||
|
||||
// On first load, seed the ref with the current remote timestamp so that
|
||||
// subsequent effect firings (e.g. after window focus) don't re-apply the
|
||||
// same snapshot. We still apply this snapshot below so that settings
|
||||
// restored from the query cache (seeded by useInitialSync) take effect
|
||||
// immediately on page reload without waiting for the 5-second delay.
|
||||
// On first load, mark seeded so this block only runs once.
|
||||
// We intentionally do NOT pre-set lastSyncedTimestamp here — leaving it
|
||||
// at 0 lets the `remoteSync <= lastSyncedTimestamp` guard below fall
|
||||
// through so the settings are actually applied on this first pass.
|
||||
// Line 277 then records the timestamp to prevent re-application.
|
||||
if (!seededTimestamp) {
|
||||
lastSyncedTimestamp.current = remoteSync;
|
||||
setSeededTimestamp(true);
|
||||
// Fall through — apply the settings this time so that sidebarOrder
|
||||
// and other fields are always applied on the first load.
|
||||
}
|
||||
|
||||
// Don't overwrite local config if we just saved settings (short-circuit for
|
||||
@@ -434,6 +431,12 @@ export function NostrSync() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the sync timestamp so the next page load can render immediately
|
||||
// from localStorage without showing the spinner.
|
||||
if (user && remoteSync > 0) {
|
||||
setLocalSettingsSync(user.pubkey, remoteSync);
|
||||
}
|
||||
}, [
|
||||
user,
|
||||
encryptedSettings,
|
||||
|
||||
+281
-514
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Package, X } from 'lucide-react';
|
||||
|
||||
@@ -7,7 +7,9 @@ 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 { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
|
||||
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
import { getPreviewInjectedScript } from '@/lib/previewInjectedScript';
|
||||
|
||||
interface Rect { left: number; top: number; width: number; height: number }
|
||||
|
||||
@@ -33,8 +35,8 @@ function useElementRect(el: HTMLElement | null): Rect | null {
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** The wildcard-to-localhost preview domain used by Shakespeare's iframe-fetch-client. */
|
||||
const PREVIEW_DOMAIN = 'local-shakespeare.dev';
|
||||
/** The wildcard preview domain (iframe.diy service worker sandbox). */
|
||||
const PREVIEW_DOMAIN = 'iframe.diy';
|
||||
|
||||
interface JSONRPCFetchRequest {
|
||||
jsonrpc: '2.0';
|
||||
@@ -164,8 +166,7 @@ interface NsitePreviewDialogProps {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* an iframe.diy sandbox.
|
||||
*
|
||||
* Instead of proxying requests through an nsite gateway, this component serves
|
||||
* files directly from Blossom servers using the manifest data embedded in the
|
||||
@@ -175,8 +176,11 @@ interface NsitePreviewDialogProps {
|
||||
* 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.
|
||||
* iframe.diy provides a service-worker based sandbox. The handshake is:
|
||||
* 1. iframe.diy sends a `ready` JSON-RPC notification when its SW is installed
|
||||
* 2. Parent responds with `init` notification
|
||||
* 3. iframe.diy then forwards `fetch` JSON-RPC requests for all navigations
|
||||
* 4. Parent serves files from Blossom and injects a preview script into HTML
|
||||
*/
|
||||
export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenChange }: NsitePreviewDialogProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -184,9 +188,12 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
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}`;
|
||||
// Use the NIP-5A canonical subdomain as the stable identifier, then derive
|
||||
// a private HMAC-SHA256 subdomain so the raw identifier is never exposed as
|
||||
// an iframe.diy origin (preventing cross-app localStorage/IndexedDB collisions).
|
||||
const nsiteSubdomain = getNsiteSubdomain(event);
|
||||
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
|
||||
const iframeOrigin = useMemo(() => `https://${previewSubdomain}.${PREVIEW_DOMAIN}`, [previewSubdomain]);
|
||||
const iframeSrc = `${iframeOrigin}/`;
|
||||
|
||||
// Build the manifest and server list from the event (memoised per event identity)
|
||||
@@ -207,6 +214,30 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin);
|
||||
}, [iframeOrigin]);
|
||||
|
||||
/** Virtual path where the injected preview script is served. */
|
||||
const INJECTED_SCRIPT_PATH = '/__injected__/preview.js';
|
||||
|
||||
/** Inject a <script> tag into an HTML string so the preview script runs first. */
|
||||
const injectScript = useCallback((html: string): string => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const tag = doc.createElement('script');
|
||||
tag.src = INJECTED_SCRIPT_PATH;
|
||||
doc.head.insertBefore(tag, doc.head.firstChild);
|
||||
return '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
|
||||
}, []);
|
||||
|
||||
/** Encode a string as base64. */
|
||||
const encodeBase64 = (str: string): string => btoa(unescape(encodeURIComponent(str)));
|
||||
|
||||
/** Encode raw bytes as base64. */
|
||||
const encodeBytesBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
/** Handle a fetch request from the iframe by serving files directly from Blossom. */
|
||||
const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => {
|
||||
const { params, id } = request;
|
||||
@@ -225,9 +256,26 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip query string from path for manifest lookup
|
||||
const requestedPath = requestedUrl.pathname;
|
||||
|
||||
// Serve the injected preview script at its virtual path
|
||||
if (requestedPath === INJECTED_SCRIPT_PATH) {
|
||||
sendResponse({
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
body: encodeBase64(getPreviewInjectedScript()),
|
||||
},
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the sha256 for this path in the manifest.
|
||||
// If not found, fall back to /index.html (SPA client-side routing).
|
||||
let sha256 = manifest.current.get(requestedPath);
|
||||
@@ -258,11 +306,6 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
// 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
|
||||
@@ -270,12 +313,18 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
// 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.
|
||||
// Inject preview script into HTML responses for console/navigation support
|
||||
let bodyBase64: string;
|
||||
if (contentType === 'text/html') {
|
||||
const html = new TextDecoder().decode(bytes);
|
||||
bodyBase64 = encodeBase64(injectScript(html));
|
||||
} else {
|
||||
bodyBase64 = encodeBytesBase64(bytes);
|
||||
}
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(bytes.byteLength),
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
|
||||
sendResponse({
|
||||
@@ -295,7 +344,16 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
id,
|
||||
});
|
||||
}
|
||||
}, [iframeOrigin, sendResponse]);
|
||||
}, [iframeOrigin, sendResponse, injectScript]);
|
||||
|
||||
/** Send a JSON-RPC notification to the iframe. */
|
||||
const sendNotification = useCallback((method: string, params?: Record<string, unknown>) => {
|
||||
iframeRef.current?.contentWindow?.postMessage({
|
||||
jsonrpc: '2.0' as const,
|
||||
method,
|
||||
params: params ?? {},
|
||||
}, iframeOrigin);
|
||||
}, [iframeOrigin]);
|
||||
|
||||
/** Handle navigation state updates from the iframe (no-op). */
|
||||
const handleNavigationState = useCallback((_params: {
|
||||
@@ -311,7 +369,14 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== iframeOrigin) return;
|
||||
const message = event.data;
|
||||
if (message?.jsonrpc !== '2.0') return;
|
||||
if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') return;
|
||||
|
||||
// Handle iframe.diy handshake: respond to "ready" with "init"
|
||||
if (message.method === 'ready') {
|
||||
sendNotification('init', { version: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === 'fetch') {
|
||||
handleFetch(message as JSONRPCFetchRequest);
|
||||
} else if (message.method === 'updateNavigationState') {
|
||||
@@ -320,7 +385,7 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [iframeOrigin, handleFetch, handleNavigationState]);
|
||||
}, [iframeOrigin, handleFetch, handleNavigationState, sendNotification]);
|
||||
|
||||
|
||||
|
||||
@@ -375,12 +440,11 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
{/* iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background">
|
||||
<iframe
|
||||
key={`${subdomain}-${open}`}
|
||||
key={`${previewSubdomain}-${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>,
|
||||
|
||||
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
|
||||
|
||||
/** Format a full date. */
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,68 +7,13 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
|
||||
/** Maximum badges to show in the preview grid before truncating. */
|
||||
const PREVIEW_LIMIT = 12;
|
||||
|
||||
/** A parsed badge reference from a profile badges event. */
|
||||
interface BadgeRef {
|
||||
/** The `a` tag value referencing a kind 30009 badge definition. */
|
||||
aTag: string;
|
||||
/** The `e` tag value referencing a kind 8 badge award event. */
|
||||
eTag?: string;
|
||||
/** Parsed components from the `a` tag. */
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Parse a profile badges event (kind 10008 or legacy 30008) into badge references. */
|
||||
export function parseProfileBadges(event: NostrEvent): BadgeRef[] {
|
||||
if (!isProfileBadgesKind(event.kind)) return [];
|
||||
// Legacy kind 30008 requires d=profile_badges; kind 10008 doesn't need it
|
||||
if (event.kind === 30008) {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (dTag !== 'profile_badges') return [];
|
||||
}
|
||||
|
||||
const refs: BadgeRef[] = [];
|
||||
const tags = event.tags;
|
||||
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (tags[i][0] === 'a' && tags[i][1]) {
|
||||
const aTag = tags[i][1];
|
||||
const parts = aTag.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const kind = parseInt(parts[0], 10);
|
||||
if (kind !== 30009) continue;
|
||||
|
||||
const pubkey = parts[1];
|
||||
const identifier = parts.slice(2).join(':');
|
||||
|
||||
// Look for the corresponding `e` tag immediately after
|
||||
let eTag: string | undefined;
|
||||
if (i + 1 < tags.length && tags[i + 1][0] === 'e') {
|
||||
eTag = tags[i + 1][1];
|
||||
}
|
||||
|
||||
refs.push({ aTag, eTag, kind, pubkey, identifier });
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by aTag — keep first occurrence only
|
||||
const seen = new Set<string>();
|
||||
return refs.filter((r) => {
|
||||
if (seen.has(r.aTag)) return false;
|
||||
seen.add(r.aTag);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
interface ProfileBadgesContentProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ 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';
|
||||
import { MiniAudioPlayer } from '@/components/MiniAudioPlayer';
|
||||
import { isAudioUrl, isImageUrl, isVideoUrl } from '@/lib/mediaTypeDetection';
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { parseDimToAspectRatio } from '@/components/MediaCollage';
|
||||
import { parseDimToAspectRatio } from '@/lib/mediaUtils';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
|
||||
|
||||
@@ -21,17 +21,16 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
|
||||
import {
|
||||
buildKindOptions,
|
||||
MultiKindPicker,
|
||||
ScopeToggle,
|
||||
parseSelectedKinds,
|
||||
AuthorChip,
|
||||
AuthorFilterDropdown,
|
||||
ListPackPicker,
|
||||
} from '@/components/SavedFeedFiltersEditor';
|
||||
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
import { useFollowPacks } from '@/hooks/useFollowPacks';
|
||||
import type { ProfileTab, TabFilter } from '@/lib/profileTabsEvent';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user