Compare commits

...

22 Commits

Author SHA1 Message Date
Chad Curtis 283b31813c release: v2.6.0 2026-04-05 08:31:35 -05:00
Chad Curtis 6e1197a067 Redesign LinkFooter as compact icon+label chips 2026-04-05 08:27:37 -05:00
Chad Curtis b7d1fbf860 Fix mobile sidebar bottom links clipping into safe area 2026-04-05 08:09:21 -05:00
Chad Curtis 8fde660075 Fix Blobbi page missing bg-background/85 overlay on custom themes
DashboardShell uses fixed positioning on mobile, placing it directly
over the body background image. Without the bg-background/85 class
that MainLayout's center column provides, the raw background image
showed through unthemed. Add the same 85% opacity background overlay
used consistently across the rest of the app.
2026-04-05 07:29:06 -05:00
Chad Curtis 50c7d67928 Fix blobbi state resets caused by stale cache reads and invalidation races
All blobbi mutations now follow the read-modify-write pattern: fetch fresh
state from relays before mutating, then optimistically update the cache.
This prevents two classes of bugs:

1. Stale cache reads: mutations were reading from TanStack Query cache
   (30s staleTime) instead of relays, causing newer events to be silently
   overwritten with old stats when actions happened within the cache window.

2. Invalidation races: every mutation called invalidateCompanion() after
   the optimistic update, which triggered a refetch from relays before the
   just-published event had propagated, overwriting the optimistic data
   with the pre-mutation state.

Changes:
- ensureCanonicalBlobbiBeforeAction now fetches fresh companion + profile
  from relays (the read step) instead of using cached closure values
- useBlobbiCareActivity fetches fresh companion before streak updates
- Removed all invalidateCompanion()/invalidateProfile() calls after
  optimistic updates across every action hook
- updateCompanionEvent now updates ALL blobbi-collection query caches
  for the user, not just the specific d-tag list it was instantiated with,
  keeping BlobbiPage and companion layer caches in sync
2026-04-05 07:20:26 -05:00
Chad Curtis e355c43925 Fix cross-device settings sync and smart sync gate
Settings (theme, sidebar, etc.) changed on one device were not applied
on other devices. Three root causes:

1. NostrSync seeded lastSyncedTimestamp to remoteSync on first load,
   then the guard (remoteSync <= lastSyncedTimestamp) blocked the same
   data from being applied. Settings were never applied on page reload.

2. The encrypted settings query had staleTime: Infinity and
   refetchOnWindowFocus: false, so remote changes were never fetched.

3. useInitialSync was missing customTheme, corsProxy, faviconUrl, and
   linkPreviewUrl fields.

To avoid gating every F5 behind a spinner, a lastSync timestamp is
now persisted to localStorage whenever settings are applied. On reload,
InitialSyncGate checks this: if present, render immediately from
localStorage and let NostrSync hot-swap remote changes in background.
If absent (new browser, cleared storage), show the spinner until
settings load.
2026-04-05 06:55:05 -05:00
Chad Curtis 696204870d Fix custom theme not applying on new device login
Initial sync applied the theme mode (e.g. 'custom') from encrypted
settings but not the customTheme config (colors, fonts, background),
so the theme appeared broken on first login requiring manual setup
which also triggered an unwanted kind 16767 publish.
2026-04-05 06:33:43 -05:00
Chad Curtis 0a7e01d17c Match own-profile follow link style to the following/already-following states
Use the same icon + primary semibold text + full-width button layout
instead of muted small text with an outline button.
2026-04-05 06:17:52 -05:00
Chad Curtis dd87bc96ec Fix top nav arc overlapping letter compose picker drawer
Set hasSubHeader on LetterComposePage so the MobileTopBar uses a flat
rect instead of the down-arc variant, preventing the 20px arc overhang
from painting over the LetterEditor picker panel.
2026-04-05 06:15:34 -05:00
Chad Curtis a12d5db560 Add follow URI system with QR sharing and immersive follow page
Introduce a /follow/:npub deep link that auto-follows a user when
visited by a logged-in user, or presents an immersive business card
with a 'Follow on Ditto' CTA for logged-out visitors. The page applies
the target user's profile theme, renders their feed with infinite
scroll, and uses the same banner/avatar/arc styling as the main profile.

Add a FollowQRDialog that generates a themed QR code for the follow
URL. The QR colors are derived from the active theme: primary color
for modules (with contrast-safe darkening/lightening), and background
color for the QR background. Foreground text color is used when it is
colorful and offers significantly better contrast.

Surface the QR dialog from: own profile page (top-level button),
profile more menu, desktop sidebar account popover, and mobile drawer.
2026-04-05 06:01:48 -05:00
Alex Gleason 614634789c Merge branch 'main' of nostr://npub10qdp2fc9ta6vraczxrcs8prqnv69fru2k6s2dj48gqjcylulmtjsg9arpj/relay.ngit.dev/ditto 2026-04-04 23:17:35 -05:00
Alex Gleason 29696fa3d3 Apply nearest-neighbor scaling to small custom emoji images
Custom emoji images with natural dimensions <= 16x16 now render with
image-rendering: pixelated to preserve crisp pixels instead of blurring.

Also consolidates 6 direct <img> sites to use the shared CustomEmojiImg
component so all custom emoji rendering benefits from this behavior.
2026-04-04 22:58:42 -05:00
Chad Curtis ffc31e8e8f Merge branch 'fix/blobbi-reuse-existing-eggs' into 'main'
Fix repeated egg creation and reuse existing eggs during ceremony

See merge request soapbox-pub/ditto!158
2026-04-05 02:09:45 +00:00
filemon 720a7e91fe Base ceremony decision on actual companion stages, not onboardingDone flag
The onboardingDone flag can be true on inconsistent accounts where the
user never actually hatched an egg. Now the ceremony check always waits
for companions to load and inspects their real stages:

- Any baby/adult exists: skip ceremony, auto-fix flag if needed
- Only eggs exist: ceremony with existing egg (regardless of flag)
- No companions resolved: ceremony creates a new egg

A ceremonyCheckDone flag prevents the effect from re-firing as
companion data updates during normal use.
2026-04-04 21:06:22 -03:00
filemon 05096e2cd9 Fix duplicate egg creation on every page load during onboarding
The ceremony was triggered whenever onboardingDone was false, without
waiting for companion data to load. This caused a new egg to be
published on every page visit/refresh for users mid-onboarding.

Now the decision tree waits for companions to load before deciding:
- No profile / no pets: ceremony creates a new egg (brand new user)
- Has baby/adult: skip ceremony, auto-fix onboardingDone flag
- Has only eggs: reuse an existing egg via existingCompanion prop
- Stale pet references: treat as new user

The chosen egg is locked in a ref so mid-ceremony refreshes don't
switch eggs or create duplicates.
2026-04-04 20:37:11 -03:00
filemon 05667460eb Fix first-time egg ceremony not covering RightSidebar
Portal the first-time hatching ceremony to document.body with z-[100],
matching the subsequent hatch ceremony implementation. The overlay was
previously rendered inline inside the center column's stacking context
(relative z-0), which prevented its fixed z-50 from painting over the
sibling RightSidebar.
2026-04-04 20:15:52 -03:00
Chad Curtis b10dae7655 Persist companion position across page navigations instead of replaying entry animation 2026-04-04 17:18:24 -05:00
Chad Curtis c799b9efd6 Fix crash when rendering egg: guard against undefined allTags from CompanionData cast 2026-04-04 17:14:55 -05:00
Chad Curtis fe4834e157 Remove deprecated dead code: selector modal state, useRerollMission plumbing, unused companion prop 2026-04-04 17:11:43 -05:00
Chad Curtis 5d972249a4 Fix all ESLint errors: remove unused imports, variables, and props across 4 files 2026-04-04 17:03:32 -05:00
Chad Curtis f607a01577 Fix ambiguous Tailwind duration-[2000ms] class warning 2026-04-04 16:50:56 -05:00
Chad Curtis 1e232e6a9e Blobbi hatching ceremony: immersive egg-to-blobbi experience with redesigned care UI
Replaces the old onboarding tour with a full hatching ceremony featuring golden aura,
sparkles, typewriter dialog, and fade-to-white reveal. Redesigns the BlobbiPage with
curved arc stats, floating action bubbles, overlay drawer tabs, and responsive layout.
Adds companion pill button, simplified photo modal, and egg animation styles.
Removes the old tour system (FirstHatchTour, tour hooks, tour types).
2026-04-04 16:49:51 -05:00
56 changed files with 3841 additions and 2729 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.5.2"
versionName "2.6.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2 -2
View File
@@ -303,7 +303,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.5.2;
MARKETING_VERSION = 2.6.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -325,7 +325,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.5.2;
MARKETING_VERSION = 2.6.0;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.5.1",
"version": "2.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.5.1",
"version": "2.5.2",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.5.2",
"version": "2.6.0",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [2.6.0] - 2026-04-05
### Added
- Follow links and QR codes -- share a link or scannable code that lets anyone follow you with one tap, complete with your themed profile preview and recent posts
- Immersive Blobbi hatching ceremony -- crack your egg through cinematic stages with shaking animations, a burst of light, sparkles, typewriter dialog, and a naming moment
### Changed
- Footer links redesigned as compact icon chips for a cleaner look
- Custom emoji now render crisp at small sizes with pixel-perfect scaling
### Fixed
- Custom themes now apply correctly when logging in on a new device
- Settings and preferences sync reliably across devices
- Mobile sidebar links no longer clip into the safe area
- Blobbi page background overlay now appears properly on custom themes
- Blobbi companion state no longer resets unexpectedly from stale cache data
- Letter compose picker no longer gets hidden behind the top navigation arc
## [2.5.2] - 2026-04-04
### Added
+4
View File
@@ -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 />} />
@@ -17,6 +17,7 @@
*/
import { useCallback, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -24,7 +25,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags } from '@/blobbi/core/lib/blobbi';
import {
KIND_BLOBBI_STATE,
updateBlobbiTags,
isValidBlobbiEvent,
parseBlobbiEvent,
} from '@/blobbi/core/lib/blobbi';
import { getStreakTagUpdates, calculateStreakUpdate, type StreakUpdateResult } from '../lib/blobbi-streak';
@@ -34,8 +40,6 @@ export interface UseBlobbiCareActivityParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
}
export interface CareActivityResult {
@@ -59,8 +63,8 @@ export interface CareActivityResult {
export function useBlobbiCareActivity({
companion,
updateCompanionEvent,
invalidateCompanion,
}: UseBlobbiCareActivityParams) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -78,12 +82,24 @@ export function useBlobbiCareActivity({
throw new Error('No companion available');
}
// Fetch fresh companion from relays (read-modify-write pattern)
const freshEvents = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [user.pubkey],
'#d': [companion.d],
}]);
const freshCompanion = freshEvents
.filter(isValidBlobbiEvent)
.sort((a, b) => b.created_at - a.created_at)
.map(e => parseBlobbiEvent(e))
.find(Boolean) ?? companion;
const now = new Date();
// Calculate what the streak update should be
// Calculate what the streak update should be using fresh data
const result = calculateStreakUpdate(
companion.careStreak,
companion.careStreakLastDay,
freshCompanion.careStreak,
freshCompanion.careStreakLastDay,
now
);
@@ -96,29 +112,29 @@ export function useBlobbiCareActivity({
};
}
// Get the tag updates
const streakUpdates = getStreakTagUpdates(companion, now);
// Get the tag updates using fresh data
const streakUpdates = getStreakTagUpdates(freshCompanion, now);
if (!streakUpdates) {
// Shouldn't happen if wasUpdated is true, but handle gracefully
return {
wasUpdated: false,
newStreak: companion.careStreak ?? 0,
newStreak: freshCompanion.careStreak ?? 0,
action: 'same_day',
};
}
// Build updated tags
const updatedTags = updateBlobbiTags(companion.allTags, streakUpdates);
// Build updated tags from fresh data
const updatedTags = updateBlobbiTags(freshCompanion.allTags, streakUpdates);
// Publish the updated event
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content: companion.event.content,
content: freshCompanion.event.content,
tags: updatedTags,
});
// Update local cache
// Update local cache (optimistic — no invalidation needed)
updateCompanionEvent(event);
// Update session tracker
@@ -128,9 +144,9 @@ export function useBlobbiCareActivity({
if (import.meta.env.DEV) {
console.log('[CareActivity] Streak updated:', {
action: result.action,
previousStreak: companion.careStreak,
previousStreak: freshCompanion.careStreak,
newStreak: result.newStreak,
lastDay: companion.careStreakLastDay,
lastDay: freshCompanion.careStreakLastDay,
newDay: result.newLastDay,
});
}
@@ -141,11 +157,6 @@ export function useBlobbiCareActivity({
action: result.action,
};
},
onSuccess: (result) => {
if (result.wasUpdated) {
invalidateCompanion();
}
},
onError: (error: Error) => {
console.error('[CareActivity] Failed to update streak:', error);
},
@@ -69,10 +69,6 @@ export interface UseBlobbiDirectActionParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration happened) */
invalidateProfile: () => void;
}
/**
@@ -92,8 +88,6 @@ export function useBlobbiDirectAction({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiDirectActionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -189,12 +183,6 @@ export function useBlobbiDirectAction({
updateCompanionEvent(blobbiEvent);
// ─── Invalidate Queries ───
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
action,
happinessChange: happinessDelta,
@@ -66,10 +66,6 @@ export interface UseStartIncubationParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -112,8 +108,6 @@ export function useStartIncubation({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartIncubationParams) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -269,12 +263,6 @@ export function useStartIncubation({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -329,10 +317,6 @@ export interface UseStopIncubationParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -363,8 +347,6 @@ export function useStopIncubation({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopIncubationParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -435,12 +417,6 @@ export function useStopIncubation({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -480,10 +456,6 @@ export interface UseStartEvolutionParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -511,8 +483,6 @@ export function useStartEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStartEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -585,12 +555,6 @@ export function useStartEvolution({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -631,10 +595,6 @@ export interface UseStopEvolutionParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -665,8 +625,6 @@ export function useStopEvolution({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseStopEvolutionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -736,12 +694,6 @@ export function useStopEvolution({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
name: canonical.companion.name,
@@ -784,10 +736,6 @@ export interface UseSyncTaskCompletionsParams {
} | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
/**
@@ -827,8 +775,6 @@ export function useSyncTaskCompletions({
companion,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseSyncTaskCompletionsParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -923,11 +869,6 @@ export function useSyncTaskCompletions({
});
updateCompanionEvent(event);
invalidateCompanion();
if (canonical.wasMigrated) {
invalidateProfile();
}
if (DEBUG_TASK_SYNC) {
console.log('[TaskSync] Published successfully:', tagsToAdd);
@@ -69,10 +69,6 @@ export interface UseBlobbiStageTransitionParams {
ensureCanonicalBeforeAction: () => Promise<CanonicalActionResult | null>;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries (needed if migration occurred) */
invalidateProfile: () => void;
}
/**
@@ -113,8 +109,6 @@ export function useBlobbiHatch({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -220,12 +214,6 @@ export function useBlobbiHatch({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'egg',
@@ -268,8 +256,6 @@ export function useBlobbiEvolve({
profile,
ensureCanonicalBeforeAction,
updateCompanionEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiStageTransitionParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -376,12 +362,6 @@ export function useBlobbiEvolve({
});
updateCompanionEvent(event);
invalidateCompanion();
// Invalidate profile if migration occurred
if (canonical.wasMigrated) {
invalidateProfile();
}
return {
previousStage: 'baby',
@@ -80,10 +80,6 @@ export interface UseBlobbiUseInventoryItemParams {
updateCompanionEvent: (event: NostrEvent) => void;
/** Update profile event in local cache */
updateProfileEvent: (event: NostrEvent) => void;
/** Invalidate companion queries */
invalidateCompanion: () => void;
/** Invalidate profile queries */
invalidateProfile: () => void;
}
// Import NostrEvent type
@@ -107,8 +103,6 @@ export function useBlobbiUseInventoryItem({
ensureCanonicalBeforeAction,
updateCompanionEvent,
updateProfileEvent,
invalidateCompanion,
invalidateProfile,
}: UseBlobbiUseInventoryItemParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -145,15 +139,6 @@ export function useBlobbiUseInventoryItem({
throw new Error('Item not found in catalog');
}
// Validate item exists in storage with sufficient quantity
const storageItem = profile.storage.find(s => s.itemId === itemId);
if (!storageItem || storageItem.quantity <= 0) {
throw new Error('Item not found in your inventory');
}
if (storageItem.quantity < quantity) {
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
}
// Validate item has effects
if (!shopItem.effect) {
throw new Error('This item has no effect');
@@ -407,27 +392,29 @@ export function useBlobbiUseInventoryItem({
updateCompanionEvent(blobbiEvent);
// ─── Update Profile Storage (kind 11125) ───
// CRITICAL: Use canonical.profileStorage and canonical.profileAllTags
// instead of profile.storage/profile.allTags to avoid restoring
// stale/legacy values after migration
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
// Only decrement storage if the item actually exists in inventory.
// Items are free to use regardless of inventory state.
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
if (hasItemInStorage) {
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
storage: storageValues,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
});
updateProfileEvent(profileEvent);
updateProfileEvent(profileEvent);
}
// ─── Invalidate Queries ───
invalidateCompanion();
invalidateProfile();
// No query invalidation needed — the optimistic updates above keep the
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
// before every mutation (read-modify-write pattern).
return {
itemName: shopItem.name,
-3
View File
@@ -55,9 +55,6 @@ export {
getInteractionCount,
filterPersistentTasks,
sanitizeToHashtag,
isValidHatchPost,
isValidBlobbiPost, // Legacy export
buildHatchPhrase,
KIND_THEME_DEFINITION,
KIND_COLOR_MOMENT,
HATCH_REQUIRED_INTERACTIONS,
@@ -17,12 +17,14 @@ import { useMemo, memo, type RefObject } from 'react';
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
import type { Blobbi } from '@/blobbi/core/types/blobbi';
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
import { cn } from '@/lib/utils';
import type { CompanionData, EyeOffset, CompanionDirection } from '../types/companion.types';
@@ -248,7 +250,14 @@ export function BlobbiCompanionVisual({
)}
style={{ transformOrigin: 'center bottom' }}
>
{(companion.stage === 'baby' || companion.stage === 'adult') && (
{companion.stage === 'egg' ? (
<BlobbiStageVisual
companion={companion as unknown as BlobbiCompanion}
size="sm"
animated={false}
className="size-full"
/>
) : (
<MemoizedBlobbiVisual
stage={companion.stage}
blobbi={blobbi}
@@ -233,17 +233,18 @@ export function updateDragPosition(motion: CompanionMotion, position: Position):
}
/**
* End dragging - let gravity take over.
* End dragging - hold position where dropped.
*/
export function endDrag(motion: CompanionMotion, groundY: number): CompanionMotion {
return {
...motion,
isDragging: false,
// If already at or below ground, snap to ground
isGrounded: motion.position.y >= groundY,
// Always treat as grounded so companion holds position where dropped
isGrounded: true,
position: {
...motion.position,
y: motion.position.y >= groundY ? groundY : motion.position.y,
// Clamp to ground if below it
y: Math.min(motion.position.y, groundY),
},
};
}
@@ -104,7 +104,8 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
// Track if first entry has completed (for position initialization)
const [hasEnteredOnce, setHasEnteredOnce] = useState(false);
// Track viewport size
// Track viewport size — listen to both window resize and visualViewport
// (mobile browsers fire visualViewport resize when URL bar shows/hides)
useEffect(() => {
const handleResize = () => {
setViewport({
@@ -114,7 +115,11 @@ export function useBlobbiCompanion(): UseBlobbiCompanionResult {
};
window.addEventListener('resize', handleResize, { passive: true });
return () => window.removeEventListener('resize', handleResize);
window.visualViewport?.addEventListener('resize', handleResize, { passive: true });
return () => {
window.removeEventListener('resize', handleResize);
window.visualViewport?.removeEventListener('resize', handleResize);
};
}, []);
// Calculate bounds and positions
@@ -80,9 +80,6 @@ export function useBlobbiCompanionData(): UseBlobbiCompanionDataResult {
if (!blobbi) return null;
// Only baby and adult can be companions
if (blobbi.stage === 'egg') return null;
// Use projected stats if available, otherwise fall back to base stats
const stats = projectedState?.stats ?? blobbi.stats;
@@ -19,9 +19,8 @@
* idle -> rising -> inspecting -> entering -> complete
*
* Route change behavior:
* - Cancels current entry immediately
* - Waits 1 second
* - Restarts entry for the new page
* - Companion keeps its current position (no re-entry animation)
* - Only initial mount and companion changes trigger entry animations
*/
import { useState, useEffect, useRef, useCallback } from 'react';
@@ -310,20 +309,11 @@ export function useBlobbiEntryAnimation({
// Random entry type for new companion (fall or rise)
const entryType: EntryType = Math.random() < 0.5 ? 'fall' : 'rise';
startEntry(entryType);
} else if (routeChanged && companionId) {
// Route changed - determine direction for new route
const entryType = getEntryDirection(previousPath, pathname, sidebarOrder);
// Immediately hide Blobbi and cancel current entry
cancelEntry();
setIsHiddenForTransition(true);
// Wait 1 second, then start the new entry animation
routeChangeTimeoutRef.current = setTimeout(() => {
startEntry(entryType);
}, entryConfig.routeChangeRestartDelay);
} else if (routeChanged) {
// Route changed - companion keeps its position, no re-entry animation.
// Just update the ref so future changes compare against the new path.
}
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry, entryConfig.routeChangeRestartDelay]);
}, [isActive, pathname, companionId, sidebarOrder, startEntry, cancelEntry]);
/**
* Animation loop for FALL entry.
@@ -69,14 +69,13 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
/** Optimistically update the TanStack cache so the companion reacts immediately. */
const updateCache = useCallback((event: import('@nostrify/nostrify').NostrEvent, pubkey: string) => {
const parsed = parseBlobbiEvent(event);
if (!parsed) {
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
return;
}
if (!parsed) return;
// Optimistically update ALL blobbi-collection queries for this user.
// The cache key is ['blobbi-collection', pubkey, dListArray], so we use
// partial matching to find all entries regardless of dList shape.
// No invalidation needed — we fetched fresh from relays before mutating,
// so the optimistic update is the correct state.
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
const matchingQueries = queryClient.getQueriesData<CollectionData>({
queryKey: ['blobbi-collection', pubkey],
@@ -90,9 +89,6 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult {
companions: Object.values(newCompanionsByD),
});
}
// Also invalidate for background refetch to ensure eventual consistency
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', pubkey] });
}, [queryClient]);
const toggleSleep = useCallback(async () => {
+90 -18
View File
@@ -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 */
+33 -28
View File
@@ -132,7 +132,10 @@ export function useBlobbisCollection(dList: string[] | undefined) {
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
// Helper to invalidate and refetch after publishing
// Helper to invalidate and refetch after publishing.
// NOTE: In most mutation paths this is no longer needed — the read-modify-write
// pattern (fetch fresh → mutate → optimistic update) keeps the cache correct.
// Only call this when the set of d-tags itself changes (e.g. adoption, deletion).
const invalidate = useCallback(() => {
if (user?.pubkey && queryKeyDTags) {
queryClient.invalidateQueries({
@@ -141,36 +144,38 @@ export function useBlobbisCollection(dList: string[] | undefined) {
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Update a single companion event in the query cache (optimistic update)
// Update a single companion event in the query cache (optimistic update).
// CRITICAL: Updates ALL blobbi-collection queries for this user, not just the
// one matching the current queryKeyDTags. This ensures the BlobbiPage cache
// and companion layer cache stay in sync (they use different d-tag lists).
const updateCompanionEvent = useCallback((event: NostrEvent) => {
const parsed = parseBlobbiEvent(event);
if (!parsed || !user?.pubkey) return;
queryClient.setQueryData<{ companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] }>(
['blobbi-collection', user.pubkey, queryKeyDTags],
(prev) => {
if (!prev) {
return {
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
};
}
// Update the specific companion in the record
const newCompanionsByD = {
...prev.companionsByD,
[parsed.d]: parsed,
};
// Rebuild companions array from the record
const newCompanions = Object.values(newCompanionsByD);
return {
companionsByD: newCompanionsByD,
companions: newCompanions,
};
}
);
type CollectionData = { companionsByD: Record<string, BlobbiCompanion>; companions: BlobbiCompanion[] };
const matchingQueries = queryClient.getQueriesData<CollectionData>({
queryKey: ['blobbi-collection', user.pubkey],
});
for (const [queryKey, data] of matchingQueries) {
if (!data) continue;
const newCompanionsByD = { ...data.companionsByD, [parsed.d]: parsed };
queryClient.setQueryData<CollectionData>(queryKey, {
companionsByD: newCompanionsByD,
companions: Object.values(newCompanionsByD),
});
}
// If no existing queries matched (first load), set our own query key
if (matchingQueries.length === 0) {
queryClient.setQueryData<CollectionData>(
['blobbi-collection', user.pubkey, queryKeyDTags],
{
companionsByD: { [parsed.d]: parsed },
companions: [parsed],
},
);
}
}, [queryClient, user?.pubkey, queryKeyDTags]);
// Memoize return values for stability
@@ -190,7 +195,7 @@ export function useBlobbisCollection(dList: string[] | undefined) {
isStale: query.isStale,
/** Query error if any */
error: query.error,
/** Invalidate and refetch the collection */
/** Invalidate and refetch the collection (use only when d-tag set changes, not after mutations) */
invalidate,
/** Optimistically update a single companion in the cache */
updateCompanionEvent,
+1 -1
View File
@@ -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)
+1 -88
View File
@@ -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">
+1 -11
View File
@@ -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,
+351
View File
@@ -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">&#9660;</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&apos;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}
/>
);
}
+5 -9
View File
@@ -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,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>
);
}
-226
View File
@@ -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 };
}
-46
View File
@@ -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';
-140
View File
@@ -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
View File
@@ -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>
);
}
+1 -1
View File
@@ -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,
};
+6 -5
View File
@@ -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';
@@ -1487,11 +1488,11 @@ export function ComposeBox({
}}
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
>
<img
src={emoji.url}
alt={emoji.shortcode}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
/>
</button>
))}
</div>
+18 -1
View File
@@ -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}
/>
);
}
+4 -5
View File
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { cn } from '@/lib/utils';
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
@@ -172,12 +173,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
className="group relative"
title={`:${emoji.shortcode}:`}
>
<img
src={emoji.url}
alt={`:${emoji.shortcode}:`}
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="size-8 object-contain rounded transition-transform group-hover:scale-125"
loading="lazy"
decoding="async"
/>
</div>
))}
+4 -4
View File
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { SortableList, SortableItem } from '@/components/SortableList';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -506,11 +507,10 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
>
<div className="flex items-center gap-2 pr-2 py-1.5">
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
<img
src={emoji.url}
alt={emoji.shortcode}
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="size-8 object-contain"
loading="lazy"
/>
</div>
<div className="flex-1 min-w-0">
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import data from '@emoji-mart/data';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { cn } from '@/lib/utils';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
@@ -375,11 +376,10 @@ export function EmojiShortcodeAutocomplete({
onMouseDown={(e) => e.preventDefault()}
>
{emoji.customUrl ? (
<img
src={emoji.customUrl}
alt={`:${emoji.name}:`}
<CustomEmojiImg
name={emoji.name}
url={emoji.customUrl}
className="size-5 object-contain shrink-0"
loading="lazy"
/>
) : (
<span className="text-xl leading-none shrink-0">{emoji.native}</span>
+219
View File
@@ -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>
);
}
+30 -2
View File
@@ -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}>
+8 -1
View File
@@ -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>
);
}
+25 -14
View File
@@ -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>
);
}
+13 -3
View File
@@ -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} />
</>
);
}
+12 -9
View File
@@ -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,
+4 -5
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
import { isCustomEmoji } from '@/lib/customEmoji';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -212,12 +213,10 @@ export function QuickReactMenu({
title={`React with ${isCustom ? shortcode : emoji}`}
>
{customUrl ? (
<img
src={customUrl}
alt={emoji}
<CustomEmojiImg
name={shortcode ?? emoji}
url={customUrl}
className="size-6 object-contain"
loading="lazy"
decoding="async"
/>
) : (
emoji
+4 -4
View File
@@ -4,6 +4,7 @@ import { Sticker, Info } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CustomEmojiImg } from '@/components/CustomEmoji';
import { useCustomEmojis, type CustomEmoji } from '@/hooks/useCustomEmojis';
interface StickerPickerProps {
@@ -54,11 +55,10 @@ export function StickerPicker({ onSelect }: StickerPickerProps) {
onClick={() => onSelect(emoji)}
className="aspect-square rounded-xl overflow-hidden hover:bg-muted/80 transition-all p-1.5 group active:scale-90"
>
<img
src={emoji.url}
alt={emoji.shortcode}
<CustomEmojiImg
name={emoji.shortcode}
url={emoji.url}
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
loading="lazy"
/>
</button>
))}
+29 -3
View File
@@ -17,6 +17,28 @@ import { EncryptedSettingsSchema } from '@/lib/schemas';
*/
let lastWriteTs: number = 0;
/**
* Persist the lastSync timestamp from encrypted settings into localStorage
* so that InitialSyncGate can decide whether to show a spinner on reload.
* If a local timestamp exists, localStorage is trustworthy and the app can
* render immediately while NostrSync fetches updates in the background.
*/
export function getLocalSettingsSync(pubkey: string): number {
try {
return Number(localStorage.getItem(`ditto:settings-lastSync:${pubkey}`)) || 0;
} catch {
return 0;
}
}
export function setLocalSettingsSync(pubkey: string, lastSync: number): void {
try {
localStorage.setItem(`ditto:settings-lastSync:${pubkey}`, String(lastSync));
} catch {
// localStorage may not be available
}
}
/**
* Complete encrypted app settings stored in NIP-78
*/
@@ -112,10 +134,10 @@ export function useEncryptedSettings() {
return events[0];
},
enabled: !!user,
staleTime: Infinity,
staleTime: 5 * 60 * 1000, // 5 minutes — allows window-focus refetch to pick up cross-device changes
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnWindowFocus: true,
refetchOnReconnect: false,
});
@@ -147,7 +169,7 @@ export function useEncryptedSettings() {
}
},
enabled: !!query.data && !!user,
staleTime: Infinity,
staleTime: 0, // Always re-derive when the upstream event changes
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
@@ -216,6 +238,10 @@ export function useEncryptedSettings() {
queryClient.setQueryData(['parsedSettings', signedEvent.id], updatedSettings);
// Cache is now up to date — pending ref no longer needed
pendingSettings.current = null;
// Persist the sync timestamp so the next page load can skip the spinner
if (user && updatedSettings.lastSync) {
setLocalSettingsSync(user.pubkey, updatedSettings.lastSync);
}
},
});
+18 -1
View File
@@ -5,7 +5,7 @@ import { parseBlossomServerList } from "@/lib/appBlossom";
import { EncryptedSettingsSchema } from "@/lib/schemas";
import { useAppContext } from "./useAppContext";
import { useCurrentUser } from "./useCurrentUser";
import type { EncryptedSettings } from "./useEncryptedSettings";
import { type EncryptedSettings, setLocalSettingsSync } from "./useEncryptedSettings";
import {
type MuteListItem,
parseMuteTags,
@@ -206,6 +206,9 @@ export function useInitialSync() {
if (parsed.theme) {
updates.theme = parsed.theme;
}
if (parsed.customTheme) {
updates.customTheme = parsed.customTheme;
}
if (parsed.autoShareTheme !== undefined) {
updates.autoShareTheme = parsed.autoShareTheme;
}
@@ -227,6 +230,15 @@ export function useInitialSync() {
if (parsed.homePage) {
updates.homePage = parsed.homePage;
}
if (parsed.corsProxy) {
updates.corsProxy = parsed.corsProxy;
}
if (parsed.faviconUrl) {
updates.faviconUrl = parsed.faviconUrl;
}
if (parsed.linkPreviewUrl) {
updates.linkPreviewUrl = parsed.linkPreviewUrl;
}
return updates;
});
@@ -242,6 +254,11 @@ export function useInitialSync() {
parsed,
);
// Persist the sync timestamp so future reloads can skip the spinner
if (parsed.lastSync) {
setLocalSettingsSync(user.pubkey, parsed.lastSync);
}
foundSettings = true;
} catch (error) {
console.error(
+47
View File
@@ -652,3 +652,50 @@
@apply min-h-[350px];
}
/* Blobbi idle animations — speed/intensity driven by happiness via inline style */
@keyframes blobbi-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes blobbi-sway {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(1.5deg); }
75% { transform: rotate(-1.5deg); }
}
/* Hatch ceremony shake animations */
@keyframes blobbi-shake-light {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-3deg); }
75% { transform: rotate(3deg); }
}
@keyframes blobbi-shake-medium {
0%, 100% { transform: rotate(0deg); }
20% { transform: rotate(-6deg); }
40% { transform: rotate(5deg); }
60% { transform: rotate(-4deg); }
80% { transform: rotate(6deg); }
}
@keyframes blobbi-shake-heavy {
0%, 100% { transform: rotate(0deg) scale(1); }
15% { transform: rotate(-8deg) scale(1.02); }
30% { transform: rotate(7deg) scale(0.98); }
45% { transform: rotate(-9deg) scale(1.03); }
60% { transform: rotate(8deg) scale(0.97); }
75% { transform: rotate(-7deg) scale(1.02); }
90% { transform: rotate(9deg) scale(1); }
}
@keyframes blobbi-flash {
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes blobbi-fade-to-white {
0% { opacity: 0; }
100% { opacity: 1; }
}
+1291 -1196
View File
File diff suppressed because it is too large Load Diff
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';
import { nip19 } from 'nostr-tools';
import { UserPlus, Loader2, CheckCircle2 } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteCard } from '@/components/NoteCard';
import { getAvatarShape, isEmoji, emojiAvatarBorderStyle } from '@/lib/avatarShape';
import { cn } from '@/lib/utils';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useToast } from '@/hooks/useToast';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useProfileFeed, filterByTab } from '@/hooks/useProfileFeed';
import { genUserName } from '@/lib/genUserName';
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { DittoLogo } from '@/components/DittoLogo';
import { Nip05Badge } from '@/components/Nip05Badge';
import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme';
import { useOnboarding } from '@/hooks/useOnboarding';
import { buildThemeCssFromCore } from '@/themes';
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
import LoginDialog from '@/components/auth/LoginDialog';
import type { FeedItem } from '@/lib/feedUtils';
import NotFound from './NotFound';
// ---------------------------------------------------------------------------
// Theme application
// ---------------------------------------------------------------------------
function useApplyProfileTheme(pubkey: string | undefined) {
const themeQuery = useActiveProfileTheme(pubkey);
const theme = themeQuery.data;
useLayoutEffect(() => {
if (!theme?.colors) return;
const css = buildThemeCssFromCore(theme.colors);
let el = document.getElementById('theme-vars') as HTMLStyleElement | null;
const previousCss = el?.textContent ?? null;
if (!el) {
el = document.createElement('style');
el.id = 'theme-vars';
document.head.appendChild(el);
}
el.textContent = css;
if (theme.font) loadAndApplyFont(theme.font);
if (theme.titleFont) loadAndApplyTitleFont(theme.titleFont);
const bgStyleId = 'theme-background';
const prevBgEl = document.getElementById(bgStyleId) as HTMLStyleElement | null;
if (theme.background?.url) {
let bgEl = prevBgEl;
if (!bgEl) {
bgEl = document.createElement('style');
bgEl.id = bgStyleId;
document.head.appendChild(bgEl);
}
const mode = theme.background.mode ?? 'cover';
bgEl.textContent = mode === 'tile'
? `body { background-image: url("${theme.background.url}"); background-repeat: repeat; background-size: auto; }`
: `body { background-image: url("${theme.background.url}"); background-size: cover; background-repeat: no-repeat; background-position: center; background-attachment: fixed; }`;
} else {
prevBgEl?.remove();
}
return () => {
const styleEl = document.getElementById('theme-vars') as HTMLStyleElement | null;
if (styleEl && previousCss !== null) {
styleEl.textContent = previousCss;
} else if (styleEl) {
styleEl.remove();
}
document.getElementById(bgStyleId)?.remove();
};
}, [theme]);
}
// ---------------------------------------------------------------------------
// Profile feed (reuses useProfileFeed + NoteCard)
// ---------------------------------------------------------------------------
function ProfileFeed({ pubkey }: { pubkey: string }) {
const {
data: feedData,
isPending,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProfileFeed(pubkey, 'posts');
const feedItems = useMemo(() => {
if (!feedData?.pages) return [];
const seen = new Set<string>();
const items: FeedItem[] = [];
for (const page of feedData.pages) {
for (const item of page.items) {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (!seen.has(key)) {
seen.add(key);
items.push(item);
}
}
}
return filterByTab(items, 'posts');
}, [feedData?.pages]);
const { ref: scrollRef, inView } = useInView({ threshold: 0 });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isPending) {
return (
<div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (feedItems.length === 0) return null;
return (
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
compact
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main follow view
// ---------------------------------------------------------------------------
function FollowView({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const { user } = useCurrentUser();
const { data: followData } = useFollowList();
const { isPending, follow } = useFollowActions();
const { toast } = useToast();
const navigate = useNavigate();
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
const bannerUrl = metadata?.banner;
const { startSignup } = useOnboarding();
const isOwnProfile = user && user.pubkey === pubkey;
const isAlreadyFollowing = followData?.pubkeys.includes(pubkey) ?? false;
const isLoggedOut = !user;
const [loginOpen, setLoginOpen] = useState(false);
useApplyProfileTheme(pubkey);
const hasAutoFollowed = useRef(false);
const [followDone, setFollowDone] = useState(false);
useEffect(() => {
if (!user || isOwnProfile || isAlreadyFollowing || hasAutoFollowed.current || isPending) return;
if (!followData) return;
hasAutoFollowed.current = true;
follow(pubkey)
.then(() => {
setFollowDone(true);
toast({ title: 'Followed!', description: `You are now following ${displayName}` });
})
.catch((err) => {
console.error('Auto-follow failed:', err);
hasAutoFollowed.current = false;
toast({ title: 'Something went wrong', variant: 'destructive' });
});
}, [user, isOwnProfile, isAlreadyFollowing, followData, isPending, pubkey, follow, displayName, toast]);
return (
<div className="h-dvh flex flex-col bg-background/85">
{/* Profile header (not scrollable) */}
<div className="shrink-0">
{/* Banner — matches ProfilePage: clean edge, no gradient */}
<div className="h-36 md:h-48 bg-secondary relative">
{author.isLoading ? (
<Skeleton className="w-full h-full rounded-none" />
) : bannerUrl ? (
<img src={bannerUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 via-transparent to-primary/5" />
)}
<Link to="/" className="absolute top-3 left-3">
<div className="bg-background/85 rounded-full">
<DittoLogo size={48} />
</div>
</Link>
</div>
{/* Profile card */}
<div className="bg-background/85">
<div className="flex flex-col items-center px-4 -mt-12 md:-mt-16 relative z-10 max-w-2xl mx-auto w-full" style={{ paddingBottom: ARC_OVERHANG_PX + 16 }}>
{/* Avatar — matches ProfilePage border treatment */}
{(() => {
const avatarShape = getAvatarShape(metadata);
const isEmojiShape = !!avatarShape && isEmoji(avatarShape);
return (
<div className="relative">
<div style={isEmojiShape ? emojiAvatarBorderStyle : undefined}>
<Avatar
shape={avatarShape}
className={cn(
isEmojiShape ? 'size-[88px] md:size-[120px]' : 'size-24 md:size-32 border-4 border-background',
'shadow-lg',
)}
>
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-2xl md:text-3xl">
{displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
{(followDone || isAlreadyFollowing) && (
<div className="absolute -bottom-1 -right-1 bg-background rounded-full p-0.5 shadow">
<CheckCircle2 className="size-6 text-primary fill-primary/20" />
</div>
)}
</div>
);
})()}
{/* Name + NIP-05 */}
<div className="mt-3 text-center">
<h1 className="text-xl font-bold text-foreground">{displayName}</h1>
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey} className="justify-center mt-1" />
)}
</div>
{/* CTA — right under the name */}
<div className="mt-4 w-full max-w-xs">
{isLoggedOut ? (
<Button
onClick={() => setLoginOpen(true)}
className="w-full rounded-full py-3 text-base font-semibold"
size="lg"
>
Follow {displayName} on Ditto
</Button>
) : isOwnProfile ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<UserPlus className="size-5" />
<p className="font-semibold">
This is your follow link
</p>
</div>
<div className="flex flex-col gap-2">
<Link to={profileUrl}>
<Button className="rounded-full w-full">View your profile</Button>
</Link>
</div>
</div>
) : isPending ? (
<div className="flex flex-col items-center space-y-2">
<Loader2 className="size-6 text-primary animate-spin" />
<p className="text-sm text-muted-foreground">Following...</p>
</div>
) : followDone || isAlreadyFollowing ? (
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-2 text-primary">
<UserPlus className="size-5" />
<p className="font-semibold">
{isAlreadyFollowing && !followDone ? 'Already following!' : 'Now following!'}
</p>
</div>
<div className="flex flex-col gap-2">
<Link to={profileUrl}>
<Button className="rounded-full w-full">View profile</Button>
</Link>
<Button variant="secondary" className="rounded-full" onClick={() => navigate('/feed')}>
Go to feed
</Button>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
{/* Feed scrollbox */}
<div className="flex-1 min-h-0 overflow-y-auto relative" style={{ marginTop: -ARC_OVERHANG_PX }}>
{/* Arc with bg — sits at the top of the scroll area, overlapping the gap */}
<div className="sticky top-0 z-10 pointer-events-none" style={{ height: ARC_OVERHANG_PX }}>
<ArcBackground variant="down" />
</div>
<div className="max-w-2xl mx-auto w-full bg-background/85" style={{ paddingTop: ARC_OVERHANG_PX }}>
<ProfileFeed pubkey={pubkey} />
</div>
</div>
<LoginDialog
isOpen={loginOpen}
onClose={() => setLoginOpen(false)}
onLogin={() => setLoginOpen(false)}
onSignupClick={startSignup}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Route component
// ---------------------------------------------------------------------------
export function FollowPage() {
const { npub } = useParams<{ npub: string }>();
if (!npub) return <NotFound />;
let pubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
} else {
return <NotFound />;
}
} catch {
return <NotFound />;
}
return <FollowView pubkey={pubkey} />;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export function LetterComposePage() {
const toPubkey = searchParams.get('to') ?? undefined;
useLayoutOptions({ showFAB: false, noOverscroll: true });
useLayoutOptions({ showFAB: false, noOverscroll: true, hasSubHeader: true });
useSeoMeta({ title: 'Write a Letter' });
return (
+36 -4
View File
@@ -69,6 +69,7 @@ import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useProfileTabs } from '@/hooks/useProfileTabs';
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { ProfileRecoveryDialog } from '@/components/ProfileRecoveryDialog';
import { GiveBadgeDialog } from '@/components/GiveBadgeDialog';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
@@ -173,6 +174,7 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
const [addToListOpen, setAddToListOpen] = useState(false);
const [recoveryOpen, setRecoveryOpen] = useState(false);
const [giveBadgeOpen, setGiveBadgeOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const zapTriggerRef = useRef<HTMLSpanElement>(null);
const author = useAuthor(pubkey);
const showZap = !isOwnProfile && authorEvent && canZap(author.data?.metadata);
@@ -182,6 +184,7 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
close();
setTimeout(() => setter(true), 150);
};
const handleFollowQR = () => openAfterClose(setFollowQROpen);
const handleCopyPubkey = () => {
navigator.clipboard.writeText(npubEncoded);
@@ -265,6 +268,11 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
<Separator />
<div className="py-1">
<MenuRow
icon={<QrCode className="size-5" />}
label="Share follow link"
onClick={handleFollowQR}
/>
<MenuRow
icon={<RotateCcw className="size-5" />}
label="Profile recovery"
@@ -332,10 +340,16 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
/>
{isOwnProfile && (
<ProfileRecoveryDialog
open={recoveryOpen}
onOpenChange={setRecoveryOpen}
/>
<>
<ProfileRecoveryDialog
open={recoveryOpen}
onOpenChange={setRecoveryOpen}
/>
<FollowQRDialog
open={followQROpen}
onOpenChange={setFollowQROpen}
/>
</>
)}
{!isOwnProfile && (
@@ -912,6 +926,7 @@ export function ProfilePage() {
const [activeTab, setActiveTab] = useState<CoreProfileTab | string>('posts');
const [sidebarMediaUrl, setSidebarMediaUrl] = useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [followingModalOpen, setFollowingModalOpen] = useState(false);
const [followersModalOpen, setFollowersModalOpen] = useState(false);
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
@@ -2106,6 +2121,18 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
<Share2 className="size-5" />
</Button>
)}
{/* Follow QR code button (own profile only) */}
{isOwnProfile && (
<Button
variant="outline"
size="icon"
className="rounded-full size-10"
title="Share follow link"
onClick={() => setFollowQROpen(true)}
>
<QrCode className="size-5" />
</Button>
)}
{/* Profile reaction button */}
{!isOwnProfile && authorEvent && (
<ProfileReactionButton profileEvent={authorEvent} />
@@ -2610,6 +2637,11 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
/>
)}
{/* Follow QR dialog (own profile action bar button) */}
{isOwnProfile && (
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
)}
{/* Following List Modal */}
{profileFollowing && profileFollowing.count > 0 && (
<FollowingListModal