From e12d8eebddca262c6b7827009c0175e8595f5eef Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 05:32:36 -0300 Subject: [PATCH 01/22] Fix shake reaction: remove debug bypass, preserve SMIL eyes, stack shakes - Remove temporary `true ||` debug bypasses that made nausea trigger regardless of hunger stat. Nausea now correctly requires hunger >= 90. - Track `cycleHadNausea` so the recipe resolver uses a consistent nauseated face recipe for the entire reaction cycle, even after the green fill drains to 0. This prevents a structural SVG rebuild mid-reaction that killed SMIL spiral eye animations. - Make shake reactions additive: starting a new shake during dizzy or recovering no longer resets the reaction. Instead, the phase transitions back to shaking, nausea level can only rise (max of current and new), and the dizzy hold timer extends. Regression-of: 91de4f80 --- .../companion/hooks/useShakeReaction.ts | 160 ++++++++++++------ 1 file changed, 108 insertions(+), 52 deletions(-) diff --git a/src/blobbi/companion/hooks/useShakeReaction.ts b/src/blobbi/companion/hooks/useShakeReaction.ts index c6c4538c..c8a8ab9b 100644 --- a/src/blobbi/companion/hooks/useShakeReaction.ts +++ b/src/blobbi/companion/hooks/useShakeReaction.ts @@ -7,16 +7,22 @@ * * 1. **Shaking phase** (during drag): When shake energy crosses the * trigger threshold, Blobbi immediately looks dizzy. If nausea is - * eligible, the green body fill rises in real time as the user - * continues shaking. + * eligible (hunger >= threshold), the green body fill rises in real + * time as the user continues shaking. * * 2. **Dizzy phase** (after release): The dizzy expression and any * accumulated nausea fill are held for a duration that scales with - * the final shake intensity (~3–8 s). + * the final shake intensity (~3–8 s). Nausea fill begins draining + * immediately during this phase. * - * 3. **Recovering phase**: Nausea fill drains gradually via rAF. + * 3. **Recovering phase**: Nausea fill continues draining via rAF. * Once fully drained, transitions to idle. * + * Stacking: If the user starts a new shake during an active dizzy or + * recovering phase, the reaction continues from the current state + * instead of resetting. The nausea fill can only rise (never drops + * below its current level), and the dizzy hold timer extends. + * * Architecture notes: * - Follows the same phase/level/profile pattern as useOverstimulationReaction * - The **ShakeReactionProfile** interface enables future personality @@ -101,13 +107,10 @@ export const DIZZY_NAUSEA_PROFILE: ShakeReactionProfile = { /** * Hunger stat at or above which shaking triggers nausea (very full). - * - * TEMPORARY DEBUG: The threshold check is currently bypassed so nausea - * triggers on every shake regardless of hunger. See the `isNauseated` - * assignment inside `onDragUpdate` and `onDragEnd`. Restore the real - * threshold by removing the `true ||` override. + * When hunger is below this, Blobbi still gets dizzy but without the + * green body fill escalation. */ -const _NAUSEA_HUNGER_THRESHOLD = 90; +const NAUSEA_HUNGER_THRESHOLD = 90; /** Minimum dizzy duration (seconds) for a barely-qualifying shake. */ const MIN_DIZZY_DURATION_S = 3; @@ -147,7 +150,7 @@ export interface UseShakeReactionResult { onDragUpdate: (result: ShakeResult) => void; /** Call this when drag ends with the final ShakeResult. */ onDragEnd: (result: ShakeResult) => void; - /** Call this when drag starts (resets any active reaction). */ + /** Call this when drag starts. Does not reset active reactions (stacking). */ onDragStart: () => void; } @@ -161,6 +164,11 @@ export function useShakeReaction({ // ── Visible state (throttled) ── const [visibleNauseaLevel, setVisibleNauseaLevel] = useState(0); const [phase, setPhase] = useState('idle'); + /** Whether nausea was activated at any point during the current cycle. + * Promoted to React state so the recipe resolver can use a consistent + * face recipe (nauseated) even after the fill fully drains, preventing + * a structural SVG rebuild that would kill SMIL spiral animations. */ + const [cycleHadNausea, setCycleHadNausea] = useState(false); // ── Refs for high-frequency data ── const nauseaLevelRef = useRef(0); @@ -172,6 +180,11 @@ export function useShakeReaction({ const hungerRef = useRef(hunger); hungerRef.current = hunger; const toastShownRef = useRef(false); + /** Whether nausea was triggered at any point during this reaction cycle. + * Used by the recipe resolver to keep the nauseated face (same structural + * recipe) even after the fill drains to 0, avoiding an SVG rebuild that + * would kill SMIL spiral eye animations mid-reaction. */ + const cycleHadNauseaRef = useRef(false); const profileRef = useRef(profile); profileRef.current = profile; @@ -206,6 +219,14 @@ export function useShakeReaction({ } }, []); + /** Transition the full cycle to idle, cleaning up all cycle-scoped state. */ + const finishCycle = useCallback(() => { + pushVisible(0, 'idle'); + toastShownRef.current = false; + cycleHadNauseaRef.current = false; + setCycleHadNausea(false); + }, [pushVisible]); + // ── rAF nausea drain loop ── // Runs during BOTH 'dizzy' and 'recovering' phases so the green fill // begins descending the moment shaking stops, not only after the dizzy @@ -224,9 +245,7 @@ export function useShakeReaction({ // During dizzy hold, keep the phase — the timer will handle the // dizzy→idle transition. During recovering, go straight to idle. if (currentPhase === 'recovering') { - pushVisible(0, 'idle'); - // Clean up cycle-scoped refs so next shake starts fresh. - toastShownRef.current = false; + finishCycle(); } else { pushVisible(0, 'dizzy'); } @@ -239,8 +258,7 @@ export function useShakeReaction({ if (newLevel <= 0) { if (currentPhase === 'recovering') { - pushVisible(0, 'idle'); - toastShownRef.current = false; + finishCycle(); } else { pushVisible(0, 'dizzy'); } @@ -256,7 +274,7 @@ export function useShakeReaction({ } rafRef.current = requestAnimationFrame(rafTick); - }, [pushVisible, clearRaf]); + }, [pushVisible, clearRaf, finishCycle]); const startRafLoop = useCallback(() => { if (rafRef.current !== null) return; @@ -276,40 +294,56 @@ export function useShakeReaction({ phaseRef.current = 'idle'; lastVisibleRef.current = 0; toastShownRef.current = false; + cycleHadNauseaRef.current = false; setVisibleNauseaLevel(0); setPhase('idle'); + setCycleHadNausea(false); }, [clearRaf, clearDizzyTimer]); - // ── Drag start: cancel any active reaction ── + // ── Drag start: prepare for potential shake ── + // If an active reaction is running (dizzy/recovering), we do NOT reset. + // The new shake will build on the current state — onDragUpdate handles + // the transition back to 'shaking' from any active phase. const onDragStart = useCallback(() => { - if (phaseRef.current !== 'idle') { - resetAll(); + // Only reset the toast guard when starting fresh from idle. + // During an active reaction, let the existing toast state persist. + if (phaseRef.current === 'idle') { + toastShownRef.current = false; } - // Fresh drag — reset toast guard for this cycle - toastShownRef.current = false; - }, [resetAll]); + }, []); // ── Live drag update: transition to shaking when threshold crossed ── const onDragUpdate = useCallback((result: ShakeResult) => { if (!result.triggered) return; - // TEMPORARY DEBUG: bypass hunger threshold so nausea triggers on every - // shake for easier testing. The real check is: - // const isNauseated = hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD; - // TODO: restore the real threshold when debug testing is complete. - // eslint-disable-next-line no-constant-binary-expression - const isNauseated = true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD; + const isNauseated = hungerRef.current >= NAUSEA_HUNGER_THRESHOLD; const nauseaLevel = isNauseated ? Math.min(1, result.intensity) : 0; - // Update nausea level live - nauseaLevelRef.current = nauseaLevel; + if (isNauseated && !cycleHadNauseaRef.current) { + cycleHadNauseaRef.current = true; + setCycleHadNausea(true); + } - // Transition idle → shaking on first trigger - if (phaseRef.current === 'idle' || phaseRef.current === 'shaking') { - pushVisible(nauseaLevel, 'shaking'); + // Update nausea level live — take the max of current and new so that + // re-shaking during an active reaction can only raise the fill, never + // drop it below what's already accumulated. + nauseaLevelRef.current = Math.max(nauseaLevelRef.current, nauseaLevel); + + // Transition idle/shaking → shaking on first trigger. + // Also absorb dizzy/recovering phases — a new shake during an active + // reaction continues from the current state instead of resetting. + const currentPhase = phaseRef.current; + if (currentPhase === 'idle' || currentPhase === 'shaking' || + currentPhase === 'dizzy' || currentPhase === 'recovering') { + // Cancel the dizzy hold timer — we're back to active shaking + if (currentPhase === 'dizzy' || currentPhase === 'recovering') { + clearDizzyTimer(); + clearRaf(); + } + pushVisible(nauseaLevelRef.current, 'shaking'); // Show nausea warning toast once per shake cycle if (isNauseated && !toastShownRef.current) { @@ -320,9 +354,14 @@ export function useShakeReaction({ }); } } - }, [pushVisible]); + }, [pushVisible, clearDizzyTimer, clearRaf]); // ── Drag end: finalize and hold dizzy ── + // + // If the user was actively shaking (phase === 'shaking'), transition + // to the dizzy hold. The nausea level takes the max of the current + // (possibly mid-drain from a prior shake) and the new shake's level, + // so re-shaking during recovery can only raise the fill. const onDragEnd = useCallback((result: ShakeResult) => { // If we were in shaking phase, always finalize (even if the @@ -336,23 +375,25 @@ export function useShakeReaction({ const dizzyDurationS = MIN_DIZZY_DURATION_S + intensity * (MAX_DIZZY_DURATION_S - MIN_DIZZY_DURATION_S); - // TEMPORARY DEBUG: bypass hunger threshold (same as onDragUpdate). - // TODO: restore the real threshold when debug testing is complete. - // eslint-disable-next-line no-constant-binary-expression - const isNauseated = true || hungerRef.current >= _NAUSEA_HUNGER_THRESHOLD; - const nauseaLevel = isNauseated ? Math.min(1, intensity) : 0; + const isNauseated = hungerRef.current >= NAUSEA_HUNGER_THRESHOLD; + const newNauseaLevel = isNauseated ? Math.min(1, intensity) : 0; - // Lock in the final nausea level and start draining immediately - nauseaLevelRef.current = nauseaLevel; - pushVisible(nauseaLevel, 'dizzy'); + // Take the max of current and new — re-shaking only raises the fill + const effectiveLevel = Math.max(nauseaLevelRef.current, newNauseaLevel); + + // Lock in the effective nausea level and start draining immediately + nauseaLevelRef.current = effectiveLevel; + pushVisible(effectiveLevel, 'dizzy'); // Start the rAF drain loop right away so the green fill begins // descending during the dizzy hold, not only after it ends. - if (nauseaLevel > 0) { + if (effectiveLevel > 0) { + // Stop existing loop (if mid-drain) and restart with fresh timing + clearRaf(); startRafLoopRef.current(); } - // Schedule end of dizzy hold + // Reschedule the dizzy hold timer — a new shake extends the hold clearDizzyTimer(); dizzyTimerRef.current = setTimeout(() => { dizzyTimerRef.current = null; @@ -362,11 +403,10 @@ export function useShakeReaction({ // already running, it will pick up the new phase automatically) pushVisible(nauseaLevelRef.current, 'recovering'); } else { - pushVisible(0, 'idle'); - toastShownRef.current = false; + finishCycle(); } }, dizzyDurationS * 1000); - }, [pushVisible, clearDizzyTimer]); + }, [pushVisible, clearDizzyTimer, clearRaf, finishCycle]); // ── Reset on deactivation ── @@ -386,6 +426,12 @@ export function useShakeReaction({ }, [clearRaf, clearDizzyTimer]); // ── Resolve phase + nausea level → recipe ── + // + // Key invariant: once nausea activates in a cycle, the nauseated face + // recipe is used for ALL remaining non-idle phases — even after the + // green fill fully drains to 0. This prevents a structural recipe + // switch (nauseated → dizzy) mid-reaction, which would trigger an SVG + // rebuild and kill the running SMIL spiral eye animation. const result = useMemo((): UseShakeReactionResult => { const base = { onDragUpdate, onDragEnd, onDragStart }; @@ -396,8 +442,13 @@ export function useShakeReaction({ const p = profileRef.current; - // ── Nauseated: dizzy face + green body fill ── - if (visibleNauseaLevel > 0) { + // Use the nauseated face for the entire cycle if nausea was triggered, + // even when the fill has drained to 0. This keeps the structural recipe + // stable so SMIL animations survive. + const useNauseatedFace = cycleHadNausea; + + // ── Active nausea fill ── + if (visibleNauseaLevel > 0 && useNauseatedFace) { const recipe: BlobbiVisualRecipe = { ...p.nauseated.recipe, bodyEffects: { @@ -414,10 +465,15 @@ export function useShakeReaction({ return { ...base, phase, nauseaLevel: visibleNauseaLevel, recipe, recipeLabel: p.nauseated.label }; } - // ── Dizzy only (no nausea fill) ── + // ── Nauseated face without fill (fill drained but cycle still active) ── + if (useNauseatedFace) { + return { ...base, phase, nauseaLevel: 0, recipe: p.nauseated.recipe, recipeLabel: p.nauseated.label }; + } + + // ── Dizzy only (no nausea in this cycle) ── return { ...base, phase, nauseaLevel: 0, recipe: p.dizzy.recipe, recipeLabel: p.dizzy.label }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [phase, visibleNauseaLevel, profile, onDragUpdate, onDragEnd, onDragStart]); + }, [phase, visibleNauseaLevel, cycleHadNausea, profile, onDragUpdate, onDragEnd, onDragStart]); return result; } From e2ce575b25971b29ae9bc50cfc4712b98c6dc002 Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 06:46:17 -0300 Subject: [PATCH 02/22] Separate progression state from activity state to prevent evolution reset Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently destroying evolution progress. The root cause was using a single state tag for two orthogonal concerns: activity (active/sleeping/hibernating) and progression (incubating/evolving). Introduce progression_state and progression_started_at as new tags orthogonal to the activity state tag. Sleep, wake, and hibernation changes now never touch progression. Parser auto-migrates legacy events that stored progression in the state tag on read. --- .../components/BlobbiMissionsModal.tsx | 4 +- .../components/StartEvolutionDialog.tsx | 2 +- .../components/StartIncubationDialog.tsx | 4 +- .../actions/hooks/useActiveTaskProcess.ts | 4 +- .../actions/hooks/useBlobbiDirectAction.ts | 4 +- .../actions/hooks/useBlobbiIncubation.ts | 37 ++++---- .../actions/hooks/useBlobbiStageTransition.ts | 15 ++-- .../hooks/useBlobbiUseInventoryItem.ts | 4 +- src/blobbi/actions/hooks/useEvolveTasks.ts | 2 +- src/blobbi/actions/hooks/useHatchTasks.ts | 2 +- .../companion/interaction/useBlobbiItemUse.ts | 4 +- src/blobbi/core/lib/blobbi-tag-schema.ts | 43 +++++++-- src/blobbi/core/lib/blobbi.ts | 89 +++++++++++++++++-- src/blobbi/core/types/blobbi.ts | 7 +- src/blobbi/dev/useBlobbiDevUpdate.ts | 11 ++- .../components/BlobbiHatchingCeremony.tsx | 10 ++- src/blobbi/onboarding/lib/blobbi-preview.ts | 14 ++- src/pages/BlobbiPage.tsx | 4 +- src/types/blobbi.ts | 7 +- 19 files changed, 201 insertions(+), 66 deletions(-) diff --git a/src/blobbi/actions/components/BlobbiMissionsModal.tsx b/src/blobbi/actions/components/BlobbiMissionsModal.tsx index 1244bc21..8b6aa2ee 100644 --- a/src/blobbi/actions/components/BlobbiMissionsModal.tsx +++ b/src/blobbi/actions/components/BlobbiMissionsModal.tsx @@ -434,8 +434,8 @@ export function BlobbiMissionsModal({ showMissionCard, onToggleMissionCard, }: BlobbiMissionsModalProps) { - const isIncubating = companion.state === 'incubating'; - const isEvolvingState = companion.state === 'evolving'; + const isIncubating = companion.progressionState === 'incubating'; + const isEvolvingState = companion.progressionState === 'evolving'; const isEgg = companion.stage === 'egg'; const isBaby = companion.stage === 'baby'; diff --git a/src/blobbi/actions/components/StartEvolutionDialog.tsx b/src/blobbi/actions/components/StartEvolutionDialog.tsx index 5609d730..e9e41363 100644 --- a/src/blobbi/actions/components/StartEvolutionDialog.tsx +++ b/src/blobbi/actions/components/StartEvolutionDialog.tsx @@ -46,7 +46,7 @@ export function StartEvolutionDialog({ isPending, }: StartEvolutionDialogProps) { // Check if the current Blobbi is already evolving - const isAlreadyEvolving = companion?.state === 'evolving'; + const isAlreadyEvolving = companion?.progressionState === 'evolving'; // Determine title and description based on state const getDialogContent = () => { diff --git a/src/blobbi/actions/components/StartIncubationDialog.tsx b/src/blobbi/actions/components/StartIncubationDialog.tsx index a67a856a..f752c79e 100644 --- a/src/blobbi/actions/components/StartIncubationDialog.tsx +++ b/src/blobbi/actions/components/StartIncubationDialog.tsx @@ -54,14 +54,14 @@ export function StartIncubationDialog({ isPending, }: StartIncubationDialogProps) { // Check if the current Blobbi is already in a task state - const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving'; + const isAlreadyInTaskState = companion?.progressionState === 'incubating' || companion?.progressionState === 'evolving'; // Check if another Blobbi (not this one) is currently incubating const otherIncubatingBlobbi = useMemo(() => { if (!companion) return null; return companions.find(c => c.d !== companion.d && - c.state === 'incubating' && + c.progressionState === 'incubating' && c.stage === 'egg' ) ?? null; }, [companion, companions]); diff --git a/src/blobbi/actions/hooks/useActiveTaskProcess.ts b/src/blobbi/actions/hooks/useActiveTaskProcess.ts index 4cb50beb..723a080a 100644 --- a/src/blobbi/actions/hooks/useActiveTaskProcess.ts +++ b/src/blobbi/actions/hooks/useActiveTaskProcess.ts @@ -139,8 +139,8 @@ export function useActiveTaskProcess( // Determine which process is active const processType = useMemo((): TaskProcessType => { if (!companion) return null; - if (companion.state === 'incubating') return 'hatch'; - if (companion.state === 'evolving') return 'evolve'; + if (companion.progressionState === 'incubating') return 'hatch'; + if (companion.progressionState === 'evolving') return 'evolve'; return null; }, [companion]); diff --git a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts index 8c83719a..06e2149d 100644 --- a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts +++ b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts @@ -147,9 +147,9 @@ export function useBlobbiDirectAction({ const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = canonical.companion.state; + const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user.pubkey); } diff --git a/src/blobbi/actions/hooks/useBlobbiIncubation.ts b/src/blobbi/actions/hooks/useBlobbiIncubation.ts index e90e6696..b28fb02b 100644 --- a/src/blobbi/actions/hooks/useBlobbiIncubation.ts +++ b/src/blobbi/actions/hooks/useBlobbiIncubation.ts @@ -181,17 +181,18 @@ export function useStartIncubation({ // Apply decay to the other Blobbi const otherDecayResult = applyBlobbiDecay({ stage: 'egg', - state: 'incubating', + state: 'active', stats: otherStats, lastDecayAt: otherLastDecayAt, now, }); - // Remove task tags and state_started_at from the other Blobbi + // Remove task tags and progression timing from the other Blobbi const otherCleanedTags = otherEvent.tags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); const otherNewTags = updateBlobbiTags(otherCleanedTags, { @@ -200,7 +201,7 @@ export function useStartIncubation({ happiness: otherDecayResult.stats.happiness.toString(), hunger: '100', energy: '100', - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); @@ -254,8 +255,8 @@ export function useStartIncubation({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'incubating', - state_started_at: nowStr, + progression_state: 'incubating', + progression_started_at: nowStr, last_interaction: nowStr, last_decay_at: nowStr, }); @@ -375,7 +376,7 @@ export function useStopIncubation({ throw new Error('No companion selected'); } - if (companion.state !== 'incubating') { + if (companion.progressionState !== 'incubating') { throw new Error('This Blobbi is not incubating'); } @@ -398,11 +399,12 @@ export function useStopIncubation({ }); // ─── Build Updated Tags ─── - // Remove task tags and state_started_at + // Remove task tags and progression timing const cleanedTags = canonical.allTags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); // Build stats update with decayed values @@ -417,7 +419,7 @@ export function useStopIncubation({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); @@ -510,7 +512,7 @@ export function useStartEvolution({ throw new Error('Only baby Blobbis can evolve'); } - if (companion.state === 'evolving') { + if (companion.progressionState === 'evolving') { throw new Error('This Blobbi is already evolving'); } @@ -549,8 +551,8 @@ export function useStartEvolution({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'evolving', - state_started_at: nowStr, + progression_state: 'evolving', + progression_started_at: nowStr, last_interaction: nowStr, last_decay_at: nowStr, }); @@ -656,7 +658,7 @@ export function useStopEvolution({ throw new Error('No companion selected'); } - if (companion.state !== 'evolving') { + if (companion.progressionState !== 'evolving') { throw new Error('This Blobbi is not evolving'); } @@ -679,11 +681,12 @@ export function useStopEvolution({ }); // ─── Build Updated Tags ─── - // Remove task tags and state_started_at + // Remove task tags and progression timing const cleanedTags = canonical.allTags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); // Build stats update with decayed values @@ -697,7 +700,7 @@ export function useStopEvolution({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); diff --git a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts index c42d1d96..f9b8107f 100644 --- a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts +++ b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts @@ -201,12 +201,12 @@ export function useBlobbiHatch({ } // ─── Auto-start evolution for newly hatched babies ─── - // Applied AFTER tag validation because cleanupTaskTags repairs - // task-process states to 'active'. We intentionally set 'evolving' - // here so the baby starts its evolution journey immediately. + // Applied AFTER tag validation because cleanupTaskTags clears + // progression tags. We set the new progression_state here so the + // baby starts its evolution journey immediately. const newTags = updateBlobbiTags(repairResult.tags, { - state: 'evolving', - state_started_at: nowStr, + progression_state: 'evolving', + progression_started_at: nowStr, }); // ─── Generate New Content for Baby Stage ─── @@ -355,7 +355,10 @@ export function useBlobbiEvolve({ console.log('[Evolve] Tag repairs applied:', repairResult.repairs); } - const newTags = repairResult.tags; + // Ensure progression is cleared after evolve + const newTags = updateBlobbiTags(repairResult.tags, { + progression_state: 'none', + }); // ─── Generate New Content for Adult Stage ─── // CRITICAL: Content must reflect the new stage diff --git a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts index 6b31f7ca..e7d6dbd7 100644 --- a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts +++ b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts @@ -241,9 +241,9 @@ export function useBlobbiUseInventoryItem({ const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = canonical.companion.state; + const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user?.pubkey); } diff --git a/src/blobbi/actions/hooks/useEvolveTasks.ts b/src/blobbi/actions/hooks/useEvolveTasks.ts index 795ae35c..1e61cb04 100644 --- a/src/blobbi/actions/hooks/useEvolveTasks.ts +++ b/src/blobbi/actions/hooks/useEvolveTasks.ts @@ -90,7 +90,7 @@ export function useEvolveTasks( const { nostr } = useNostr(); const pubkey = user?.pubkey; - const isEvolving = companion?.state === 'evolving'; + const isEvolving = companion?.progressionState === 'evolving'; const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index 1a71c087..400050a4 100644 --- a/src/blobbi/actions/hooks/useHatchTasks.ts +++ b/src/blobbi/actions/hooks/useHatchTasks.ts @@ -109,7 +109,7 @@ export function useHatchTasks( const { nostr } = useNostr(); const pubkey = user?.pubkey; - const isIncubating = companion?.state === 'incubating'; + const isIncubating = companion?.progressionState === 'incubating'; const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── diff --git a/src/blobbi/companion/interaction/useBlobbiItemUse.ts b/src/blobbi/companion/interaction/useBlobbiItemUse.ts index daa628be..ba2b0f8b 100644 --- a/src/blobbi/companion/interaction/useBlobbiItemUse.ts +++ b/src/blobbi/companion/interaction/useBlobbiItemUse.ts @@ -351,9 +351,9 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = companion.state; + const progressionState = companion.progressionState; const updatedTags = companion.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user?.pubkey); } diff --git a/src/blobbi/core/lib/blobbi-tag-schema.ts b/src/blobbi/core/lib/blobbi-tag-schema.ts index 809591ae..2b2e0f4f 100644 --- a/src/blobbi/core/lib/blobbi-tag-schema.ts +++ b/src/blobbi/core/lib/blobbi-tag-schema.ts @@ -382,8 +382,8 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ persistent: false, source: 'system', regenerable: false, - format: 'active | sleeping | hibernating | incubating | evolving', - notes: 'incubating is for eggs, evolving is for babies. Reset to active after transition.', + format: 'active | sleeping | hibernating', + notes: 'Activity state only. Progression (incubation/evolution) is tracked in progression_state.', }, { tag: 'last_interaction', @@ -414,8 +414,21 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ // TASK SYSTEM TAGS // ═══════════════════════════════════════════════════════════════════════════ { - tag: 'state_started_at', - description: 'Unix timestamp when current state (incubating/evolving) started', + tag: 'progression_state', + description: 'Current progression process state (orthogonal to activity state)', + category: 'task', + required: false, + stages: ['egg', 'baby', 'adult'], + persistent: false, + source: 'system', + regenerable: false, + format: 'none | incubating | evolving', + defaultValue: 'none', + notes: 'Set to incubating/evolving when a progression process starts. Cleared to none after hatch/evolve completes. Survives sleep/wake/hibernation changes.', + }, + { + tag: 'progression_started_at', + description: 'Unix timestamp when current progression (incubating/evolving) started', category: 'task', required: false, stages: ['egg', 'baby', 'adult'], @@ -425,6 +438,18 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ format: 'Unix timestamp (seconds)', notes: 'Set when entering incubating/evolving. REMOVED after hatch/evolve completes.', }, + { + tag: 'state_started_at', + description: '@deprecated Use progression_started_at instead. Unix timestamp when current state (incubating/evolving) started', + category: 'task', + required: false, + stages: ['egg', 'baby', 'adult'], + persistent: false, + source: 'system', + regenerable: false, + format: 'Unix timestamp (seconds)', + notes: 'Legacy tag. New events should use progression_started_at instead.', + }, { tag: 'task', description: 'Task progress tracking', @@ -668,7 +693,7 @@ export function getPersistentTagNames(): Set { * These are task-related and state-specific tags. */ export function getTransitionCleanupTagNames(): Set { - return new Set(['task', 'task_completed', 'state_started_at']); + return new Set(['task', 'task_completed', 'state_started_at', 'progression_started_at', 'progression_state']); } /** @@ -776,13 +801,15 @@ const NEVER_INVENT_TAGS = new Set([ * Valid states for each stage. Used to validate/repair state after transitions. */ const VALID_STATES_BY_STAGE: Record> = { - egg: new Set(['active', 'sleeping', 'hibernating', 'incubating']), - baby: new Set(['active', 'sleeping', 'hibernating', 'evolving']), + egg: new Set(['active', 'sleeping', 'hibernating']), + baby: new Set(['active', 'sleeping', 'hibernating']), adult: new Set(['active', 'sleeping', 'hibernating']), }; /** - * Task-process states that should NOT remain after stage transitions. + * Task-process states that should NOT remain in the `state` tag after stage transitions. + * With the new model, state should never be 'incubating'/'evolving' — but we keep this + * for migration: if a legacy event has them in state, the repair will fix it. */ const TASK_PROCESS_STATES = new Set(['incubating', 'evolving']); diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 276bad25..1163d99a 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -94,7 +94,16 @@ export function getDaysDifference(dayA: string, dayB: string): number { // ─── Types ──────────────────────────────────────────────────────────────────── export type BlobbiStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + * + * 'none' — no progression process active + * 'incubating' — egg is being incubated (hatch tasks) + * 'evolving' — baby is being evolved (evolve tasks) + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; export interface BlobbiStats { hunger: number; @@ -239,8 +248,10 @@ export interface BlobbiCompanion { name: string; /** Lifecycle stage */ stage: BlobbiStage; - /** Activity state */ + /** Activity state (active, sleeping, hibernating — never progression) */ state: BlobbiState; + /** Progression process state (none, incubating, evolving — orthogonal to state) */ + progressionState: BlobbiProgressionState; /** Deterministic identity seed (64-char hex) */ seed: string | undefined; /** Visual traits (derived from seed or legacy tags) */ @@ -277,8 +288,14 @@ export interface BlobbiCompanion { startIncubation: number | undefined; /** Adult evolution form type (adult only) */ adultType: string | undefined; - /** Timestamp when current state (incubating/evolving) started (unix seconds) */ + /** + * @deprecated Use progressionStartedAt instead. + * Timestamp when current state (incubating/evolving) started (unix seconds). + * Kept for migration compatibility. + */ stateStartedAt: number | undefined; + /** Timestamp when current progression (incubating/evolving) started (unix seconds) */ + progressionStartedAt: number | undefined; /** Task progress cache (source of truth is computed from Nostr events) */ tasks: BlobbiTaskProgress[]; /** Completed task names */ @@ -769,6 +786,8 @@ export function isValidBlobbiEvent(event: NostrEvent): boolean { if (!d) return false; if (b !== BLOBBI_ECOSYSTEM_NAMESPACE) return false; if (!stage || !['egg', 'baby', 'adult'].includes(stage)) return false; + // Accept both new states (active/sleeping/hibernating) and legacy states (incubating/evolving) + // for backwards compatibility during migration if (!state || !['active', 'sleeping', 'hibernating', 'incubating', 'evolving'].includes(state)) return false; if (!lastInteraction) return false; @@ -887,9 +906,33 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined const d = getTagValue(tags, 'd')!; const nameTag = getTagValue(tags, 'name'); const stage = getTagValue(tags, 'stage') as BlobbiStage; - const state = getTagValue(tags, 'state') as BlobbiState; + const rawState = getTagValue(tags, 'state')!; const seed = getTagValue(tags, 'seed'); + // ─── Progression state resolution (migration-aware) ─── + // New model: progression lives in progression_state tag. + // Old model: progression lived in the state tag ('incubating', 'evolving'). + // On read we normalise both into the new model. + const progressionStateTag = getTagValue(tags, 'progression_state') as BlobbiProgressionState | undefined; + + let state: BlobbiState; + let progressionState: BlobbiProgressionState; + + if (progressionStateTag) { + // New-format event: progression_state tag is authoritative + state = rawState as BlobbiState; + progressionState = progressionStateTag; + } else if (rawState === 'incubating' || rawState === 'evolving') { + // Legacy event: progression was stored in state tag. + // Normalise: move it to progressionState, set activity state to 'active'. + state = 'active'; + progressionState = rawState as BlobbiProgressionState; + } else { + // No progression + state = rawState as BlobbiState; + progressionState = 'none'; + } + // Resolve name: tag > legacy d-tag derivation > fallback const name = nameTag ?? deriveNameFromLegacyD(d); @@ -933,6 +976,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined name, stage, state, + progressionState, seed, visualTraits, isLegacy, @@ -955,6 +999,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined startIncubation: parseNumericTag(tags, 'start_incubation'), adultType: getTagValue(tags, 'adult_type'), stateStartedAt: parseNumericTag(tags, 'state_started_at'), + progressionStartedAt: parseNumericTag(tags, 'progression_started_at') ?? parseNumericTag(tags, 'state_started_at'), tasks, tasksCompleted, allTags: tags, @@ -1045,6 +1090,7 @@ export function buildEggTags( ['name', name], ['stage', 'egg'], ['state', 'active'], + ['progression_state', 'none'], ['seed', seed], ['generation', '1'], ['breeding_ready', 'false'], @@ -1094,6 +1140,8 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([ 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', // Social/flag tags 'breeding_ready', + // Progression tags (orthogonal to activity state) + 'progression_state', 'progression_started_at', // Task system tags (removed after stage transitions) 'state_started_at', 'task', 'task_completed', // Evolution tags (adult only) @@ -1497,11 +1545,15 @@ export function buildMigrationTags( // Per blobbi-tag-schema.md: Do NOT invent values for tags that don't exist const persistentTagNames = [ // State/lifecycle tags - 'stage', 'state', + 'stage', // Stat tags 'hunger', 'happiness', 'health', 'hygiene', 'energy', // Progression tags 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', + // Progression process tags + 'progression_state', 'progression_started_at', + // Legacy progression timing (also preserve for fallback) + 'state_started_at', // Social/flag tags 'generation', 'breeding_ready', // Personality tags (preserve if they exist, do NOT generate) @@ -1519,6 +1571,33 @@ export function buildMigrationTags( } } + // ─── Normalise legacy state → progression_state ─── + // If the legacy event has state='incubating' or state='evolving', migrate + // to the new split model during migration. + const legacyState = getTagValue(legacyTags, 'state'); + if (legacyState === 'incubating' || legacyState === 'evolving') { + // Set activity state to 'active' (the progression process is tracked separately) + newTags.push(['state', 'active']); + // Only set progression_state if not already set from persistentTagNames + if (!getTagValue(legacyTags, 'progression_state')) { + newTags.push(['progression_state', legacyState]); + // Migrate state_started_at → progression_started_at if present + const startedAt = getTagValue(legacyTags, 'state_started_at'); + if (startedAt && !getTagValue(legacyTags, 'progression_started_at')) { + newTags.push(['progression_started_at', startedAt]); + } + } + } else if (legacyState) { + newTags.push(['state', legacyState]); + // Ensure progression_state is set + if (!getTagValue(legacyTags, 'progression_state')) { + newTags.push(['progression_state', 'none']); + } + } else { + newTags.push(['state', 'active']); + newTags.push(['progression_state', 'none']); + } + // ALWAYS include visual trait tags - derived from seed, with legacy tag fallbacks // This ensures every migrated event has complete visual traits for consistent rendering const visualTraits = deriveVisualTraits(legacyTags, seed); diff --git a/src/blobbi/core/types/blobbi.ts b/src/blobbi/core/types/blobbi.ts index 836fa515..beb5e0fb 100644 --- a/src/blobbi/core/types/blobbi.ts +++ b/src/blobbi/core/types/blobbi.ts @@ -16,7 +16,12 @@ * ────────────────────────────────────────────────────────────────────────── */ export type BlobbiLifeStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; /* ────────────────────────────────────────────────────────────────────────── * * Visual traits diff --git a/src/blobbi/dev/useBlobbiDevUpdate.ts b/src/blobbi/dev/useBlobbiDevUpdate.ts index 51cb3948..4c57f44d 100644 --- a/src/blobbi/dev/useBlobbiDevUpdate.ts +++ b/src/blobbi/dev/useBlobbiDevUpdate.ts @@ -79,9 +79,14 @@ export function useBlobbiDevUpdate({ tagUpdates.state = updates.state; changedFields.push('state'); - // If changing to evolving/incubating, set state_started_at - if (updates.state === 'evolving' || updates.state === 'incubating') { - tagUpdates.state_started_at = now.toString(); + // If changing to evolving/incubating (legacy dev support), set progression tags + if ((updates.state as string) === 'evolving' || (updates.state as string) === 'incubating') { + // Dev editor: treat these as progression, not activity state + tagUpdates.progression_state = updates.state; + tagUpdates.progression_started_at = now.toString(); + // Override: don't put progression in the state tag + tagUpdates.state = 'active'; + changedFields.push('progression_state'); } } diff --git a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx index 787a1c2b..fcfca748 100644 --- a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx +++ b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx @@ -188,7 +188,7 @@ export function BlobbiHatchingCeremony({ // Baby companion (same visual data but stage=baby) const babyCompanion = useMemo((): BlobbiCompanion | null => { if (!eggCompanion) return null; - return { ...eggCompanion, stage: 'baby', state: 'evolving' }; + return { ...eggCompanion, stage: 'baby', state: 'active' as const, progressionState: 'evolving' as const }; }, [eggCompanion]); const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b'; @@ -211,7 +211,8 @@ export function BlobbiHatchingCeremony({ ownerPubkey: user?.pubkey ?? '', name: existingCompanion.name, stage: 'egg', - state: (existingCompanion.state === 'incubating' ? 'incubating' : 'active') as 'incubating' | 'active', + state: 'active' as const, + progressionState: (existingCompanion.progressionState === 'incubating' ? 'incubating' : 'none') as 'incubating' | 'none', seed: existingCompanion.seed ?? '', stats: { hunger: existingCompanion.stats.hunger ?? STAT_MAX, @@ -387,8 +388,9 @@ export function BlobbiHatchingCeremony({ const babyTags = updateBlobbiTags(tags, { stage: 'baby', - state: 'evolving', - state_started_at: nowStr, + state: 'active', + progression_state: 'evolving', + progression_started_at: nowStr, hunger: STAT_MAX.toString(), happiness: STAT_MAX.toString(), health: STAT_MAX.toString(), diff --git a/src/blobbi/onboarding/lib/blobbi-preview.ts b/src/blobbi/onboarding/lib/blobbi-preview.ts index 1dac1e89..c5d63974 100644 --- a/src/blobbi/onboarding/lib/blobbi-preview.ts +++ b/src/blobbi/onboarding/lib/blobbi-preview.ts @@ -35,8 +35,10 @@ export interface BlobbiEggPreview { name: string; /** Life stage - always 'egg' for previews */ stage: 'egg'; - /** Activity state - new eggs start incubating; older eggs may be 'active' */ - state: 'incubating' | 'active'; + /** Activity state */ + state: 'active'; + /** Progression state - new eggs start incubating; older eggs may be 'none' */ + progressionState: 'incubating' | 'none'; /** Visual traits derived from seed */ visualTraits: BlobbiVisualTraits; /** Default stats for a new egg */ @@ -79,7 +81,8 @@ export function generateEggPreview( seed, name, stage: 'egg', - state: 'incubating', + state: 'active', + progressionState: 'incubating', visualTraits, stats: { ...DEFAULT_EGG_STATS }, createdAt, @@ -134,6 +137,7 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] { ['name', preview.name], ['stage', preview.stage], ['state', preview.state], + ['progression_state', preview.progressionState], ['seed', preview.seed], ['generation', '1'], ['breeding_ready', 'false'], @@ -148,7 +152,7 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] { ['energy', preview.stats.energy.toString()], ['last_interaction', now], ['last_decay_at', now], - ['state_started_at', now], + ['progression_started_at', now], // Visual trait tags - ensures deterministic rendering ['base_color', visualTraits.baseColor], ['secondary_color', visualTraits.secondaryColor], @@ -192,7 +196,9 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) { adultType: undefined, // Eggs don't have adult type // Task-related fields + progressionState: preview.progressionState, stateStartedAt: preview.createdAt, + progressionStartedAt: preview.createdAt, tasks: [], tasksCompleted: [], diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index fdd0347f..8088b78f 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -1026,8 +1026,8 @@ function BlobbiDashboard({ // State detection for tasks // Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state - const isIncubating = companion.state === 'incubating'; - const isEvolvingState = companion.state === 'evolving'; + const isIncubating = companion.progressionState === 'incubating'; + const isEvolvingState = companion.progressionState === 'evolving'; const isBaby = companion.stage === 'baby'; const canStartIncubation = isEgg && !isIncubating && !isEvolvingState; const canStartEvolution = isBaby && !isEvolvingState && !isIncubating; diff --git a/src/types/blobbi.ts b/src/types/blobbi.ts index 836fa515..beb5e0fb 100644 --- a/src/types/blobbi.ts +++ b/src/types/blobbi.ts @@ -16,7 +16,12 @@ * ────────────────────────────────────────────────────────────────────────── */ export type BlobbiLifeStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; /* ────────────────────────────────────────────────────────────────────────── * * Visual traits From bf540fb5c1eab65afd0041f5276956310e355689 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Apr 2026 22:39:27 -0500 Subject: [PATCH 03/22] Offer QR-code fallback for unsupported signers in onchain zap dialog When the user's signer can't sign PSBTs (extension without signPsbt, or a bunker that rejected sign_psbt), the onchain zap tab previously showed a dead-end 'Bitcoin zaps aren't available' panel. Replace it with an amount selector + BIP-21 QR code the user can scan from any external Bitcoin wallet, plus copy buttons for the address and payment link. Because Ditto never sees the resulting transaction, no kind 8333 is published in this flow. A warning explains that the zap won't show up as theirs on Nostr even though the recipient still gets the Bitcoin. Also gate the mempool.space UTXO and fee-rate queries on the capability being non-unsupported to avoid pointless network calls in this branch. --- src/components/OnchainZapContent.tsx | 235 ++++++++++++++++++++++++--- 1 file changed, 213 insertions(+), 22 deletions(-) diff --git a/src/components/OnchainZapContent.tsx b/src/components/OnchainZapContent.tsx index 287ed9ab..6596e392 100644 --- a/src/components/OnchainZapContent.tsx +++ b/src/components/OnchainZapContent.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; -import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin } from 'lucide-react'; +import { AlertTriangle, Zap, Gauge, Loader2, Bitcoin, Copy, Check } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; @@ -11,10 +11,13 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Textarea } from '@/components/ui/textarea'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap'; +import { useToast } from '@/hooks/useToast'; import { useNostrLogin } from '@nostrify/react/login'; import { nostrPubkeyToBitcoinAddress, @@ -77,13 +80,14 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) const { data: utxos } = useQuery({ queryKey: ['bitcoin-utxos', senderAddress], queryFn: () => fetchUTXOs(senderAddress), - enabled: !!senderAddress, + enabled: !!senderAddress && capability !== 'unsupported', staleTime: 30_000, }); const { data: feeRates } = useQuery({ queryKey: ['bitcoin-fee-rates'], queryFn: getFeeRates, + enabled: capability !== 'unsupported', staleTime: 30_000, }); @@ -164,29 +168,24 @@ export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) }, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, comment, feeSpeed, isLarge, confirmArmed]); // ── Signer not supported ────────────────────────────────────── + // The user's signer can't sign PSBTs locally (extension without signPsbt, + // or a bunker that rejected sign_psbt). Instead of a dead-end, show a QR + // they can scan with any external Bitcoin wallet. We can't observe the + // resulting txid, so we don't publish a kind 8333 — the user is warned + // that the zap won't be attributed to them on Nostr. if (user && capability === 'unsupported') { - // Tailor the hint to the login type so the user knows what to change - // to regain Bitcoin-zap capability. - const message = - loginType === 'extension' - ? "Your browser extension doesn't support sending Bitcoin. Try a different extension, or log in with your secret key." - : loginType === 'bunker' - ? "Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key." - : "Log in with your secret key to send Bitcoin zaps."; - return ( -
-
- -
-
-

Bitcoin zaps aren't available

-

- {message} -

-
-
+ ); } @@ -348,3 +347,195 @@ function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting default: return 'Processing…'; } } + +// ────────────────────────────────────────────────────────────── +// Unsupported-signer QR fallback +// ────────────────────────────────────────────────────────────── + +interface UnsupportedSignerQRProps { + recipientAddress: string; + truncatedRecipient: string; + amountSats: number; + btcPrice: number | undefined; + usdAmount: number | string; + setUsdAmount: (v: number | string) => void; + loginType: string | undefined; + onClose?: () => void; +} + +/** + * Fallback shown when the user's signer can't sign PSBTs locally. Renders a + * BIP-21 QR the user can scan with any external Bitcoin wallet. Because we + * never see the resulting tx, we skip publishing the kind 8333 zap event and + * explicitly warn the user about that. + */ +function UnsupportedSignerQR({ + recipientAddress, + truncatedRecipient, + amountSats, + btcPrice, + usdAmount, + setUsdAmount, + loginType, + onClose, +}: UnsupportedSignerQRProps) { + const { toast } = useToast(); + const [copied, setCopied] = useState<'address' | 'uri' | null>(null); + + // BIP-21 URI. Include `amount` (in BTC, 8 decimals) only when > 0 so an + // empty-amount placeholder QR doesn't include `?amount=0`. + const bip21 = useMemo(() => { + if (!recipientAddress) return ''; + if (amountSats <= 0) return `bitcoin:${recipientAddress}`; + const btc = (amountSats / 100_000_000).toFixed(8); + return `bitcoin:${recipientAddress}?amount=${btc}`; + }, [recipientAddress, amountSats]); + + const explanation = + loginType === 'extension' + ? "Your browser extension can't sign Bitcoin transactions." + : loginType === 'bunker' + ? "Your remote signer can't sign Bitcoin transactions." + : "Your signer can't sign Bitcoin transactions."; + + const copy = useCallback( + async (value: string, which: 'address' | 'uri', label: string) => { + try { + await navigator.clipboard.writeText(value); + setCopied(which); + toast({ title: 'Copied', description: `${label} copied to clipboard` }); + setTimeout(() => setCopied(null), 2000); + } catch { + toast({ title: 'Copy failed', description: 'Please copy manually.', variant: 'destructive' }); + } + }, + [toast], + ); + + const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount; + const hasAmount = amountSats > 0; + + return ( +
+

+ {explanation} You can still zap by scanning this QR from any Bitcoin wallet. +

+ + {/* Amount presets (USD) */} + { if (v) setUsdAmount(Number(v)); }} + className="grid grid-cols-5 gap-1 w-full" + > + {USD_PRESETS.map((v) => ( + + ${v} + + ))} + + +
+
+ OR +
+
+ +
+ $ + setUsdAmount(e.target.value)} + className="pl-6" + /> +
+ + {/* QR / placeholder */} +
+ {hasAmount && bip21 ? ( +
+ +
+ ) : ( +
+ {btcPrice + ? 'Choose an amount above to generate a payment QR.' + : 'Loading BTC price…'} +
+ )} +
+ + {/* Amount summary */} + {hasAmount && btcPrice && ( +
+ + {currentUsd > 0 ? `$${currentUsd}` : ''} + + + {' · '}{formatSats(amountSats)} sats + +
+ )} + + {/* Recipient */} + {recipientAddress && ( +
+
+ + To: + {truncatedRecipient} +
+
+ )} + + {/* Copy buttons */} +
+ + +
+ + {/* Warning: no kind 8333 will be published */} + + + + Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin. + + + + {onClose && ( + + )} +
+ ); +} From 476e99ab810d5337310b77cd26b1afa93b03ef63 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Apr 2026 23:33:46 -0500 Subject: [PATCH 04/22] Allow clicking the Bitcoin tab when the signer is unsupported When the Bitcoin tab only had a dead-end error panel, ZapDialog auto- switched to Lightning whenever capability was unsupported and the author had a Lightning address. Now that the Bitcoin tab renders a QR fallback the user can actually use, that forced redirect prevented them from clicking into it: every render while activeTab === 'onchain' flipped it back to 'lightning'. Drop the mid-session auto-flip effect. The initial tab choice still biases toward Lightning (via the useState initializer and the open-reset effect), but manual navigation into Bitcoin is respected. Regression-of: bf540fb5 --- src/components/ZapDialog.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index ed0f1041..aa13c979 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -388,15 +388,12 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, setInvoice]); - // If Bitcoin capability flips to `unsupported` while the dialog is open - // (e.g. a bunker just rejected `sign_psbt`) and Lightning is available, - // transparently switch to the Lightning tab. Otherwise the user would be - // stuck staring at the "Bitcoin zaps aren't available" panel. - useEffect(() => { - if (open && bitcoinUnsupported && hasLightning && activeTab === 'onchain') { - setActiveTab('lightning'); - } - }, [open, bitcoinUnsupported, hasLightning, activeTab]); + // Previously, if Bitcoin capability flipped to `unsupported` mid-session we + // auto-switched to Lightning because the Bitcoin tab was a dead-end. The + // Bitcoin tab now shows a QR fallback for unsupported signers, so users + // should be free to click into it. We only bias the *initial* tab choice + // toward Lightning (above, in the useState initializer and the open-reset + // effect); manual navigation into Bitcoin is respected. const handleZap = () => { impactMedium(); From 981e4f072652b5908ce82570f0af2ded7459d42f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Apr 2026 01:30:07 -0500 Subject: [PATCH 05/22] Prefer metadata.name, fall back to metadata.display_name everywhere Previously getDisplayName() and ~50 inline sites only consulted metadata.name, ignoring display_name. A handful of other sites used the opposite priority (display_name || name), so the same user could render under different names across the UI. Standardize on `name || display_name || genUserName(pubkey)` in the helper and at every call site, and widen two local inline metadata types in RightSidebar and SearchPage that did not declare display_name. --- .../onboarding/components/BlobbiHatchingCeremony.tsx | 2 +- src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts | 2 +- src/components/AddMembersDialog.tsx | 4 ++-- src/components/AwardBadgeDialog.tsx | 4 ++-- src/components/BadgeAwardCard.tsx | 2 +- src/components/BadgeDetailContent.tsx | 2 +- src/components/CalendarEventDetailPage.tsx | 2 +- src/components/CommentContext.tsx | 8 ++++---- src/components/CommunityContent.tsx | 4 ++-- src/components/ComposeBox.tsx | 2 +- src/components/ContentSettings.tsx | 2 +- src/components/EmbeddedCardShell.tsx | 2 +- src/components/EmbeddedNaddr.tsx | 2 +- src/components/EmbeddedNote.tsx | 4 ++-- src/components/EmbeddedPeopleListCard.tsx | 2 +- src/components/EncryptedLetterContent.tsx | 2 +- src/components/EncryptedMessageContent.tsx | 2 +- src/components/ExternalContentHeader.tsx | 4 ++-- src/components/FollowQRDialog.tsx | 2 +- src/components/InitialSyncGate.tsx | 4 ++-- src/components/InteractionsModal.tsx | 8 ++++---- src/components/LeftSidebar.tsx | 12 ++++++------ src/components/MediaCollage.tsx | 2 +- src/components/MentionAutocomplete.tsx | 2 +- src/components/MobileDrawer.tsx | 10 +++++----- src/components/MobileSearchSheet.tsx | 2 +- src/components/NostrEventSidebarItem.tsx | 2 +- src/components/NoteCard.tsx | 2 +- src/components/NoteContent.tsx | 4 ++-- src/components/NoteMoreMenu.tsx | 2 +- src/components/PeopleAvatarStack.tsx | 2 +- src/components/PollContent.tsx | 4 ++-- src/components/ProfileCard.tsx | 2 +- src/components/ProfileHoverCard.tsx | 2 +- src/components/ProfileRecoveryDialog.tsx | 2 +- src/components/ProfileSearchDropdown.tsx | 2 +- src/components/RSVPAvatars.tsx | 2 +- src/components/ReplyContext.tsx | 2 +- src/components/RightSidebar.tsx | 6 +++--- src/components/SavedFeedFiltersEditor.tsx | 4 ++-- src/components/TeamSoapboxCard.tsx | 2 +- src/components/ThemeUpdateCard.tsx | 2 +- src/components/auth/AccountSwitcher.tsx | 2 +- src/components/letter/ComposeLetterSheet.tsx | 6 +++--- src/components/letter/EnvelopeCard.tsx | 2 +- src/components/letter/LetterCard.tsx | 2 +- src/components/widgets/FeedWidget.tsx | 2 +- src/components/widgets/HotPostsWidget.tsx | 2 +- src/components/widgets/MusicWidget.tsx | 2 +- src/components/widgets/PhotoWidget.tsx | 2 +- src/hooks/useSearchProfiles.ts | 4 ++-- src/hooks/useWebxdc.ts | 2 +- src/lib/getDisplayName.ts | 9 +++++---- src/pages/BadgesPage.tsx | 2 +- src/pages/FollowPage.tsx | 6 +++--- src/pages/NotificationsPage.tsx | 6 +++--- src/pages/ProfilePage.tsx | 4 ++-- src/pages/SearchPage.tsx | 6 +++--- src/pages/UserListsPage.tsx | 2 +- 59 files changed, 99 insertions(+), 98 deletions(-) diff --git a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx index c0286422..75f8e9d9 100644 --- a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx +++ b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx @@ -224,8 +224,8 @@ export function BlobbiHatchingCeremony({ // 1. Create profile if needed if (!currentProfile) { const suggestedName = - authorData?.metadata?.display_name || authorData?.metadata?.name || + authorData?.metadata?.display_name || 'Blobbonaut'; const baseTags = buildBlobbonautTags(user.pubkey); diff --git a/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts b/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts index d4b2216e..94e60be2 100644 --- a/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts +++ b/src/blobbi/onboarding/hooks/useBlobbiOnboarding.ts @@ -168,7 +168,7 @@ export function useBlobbiOnboarding({ // Suggested name from kind 0: display_name > name > undefined const suggestedName = useMemo(() => { if (!authorData?.metadata) return undefined; - return authorData.metadata.display_name || authorData.metadata.name || undefined; + return authorData.metadata.name || authorData.metadata.display_name || undefined; }, [authorData?.metadata]); // ─── State ──────────────────────────────────────────────────────────────────── diff --git a/src/components/AddMembersDialog.tsx b/src/components/AddMembersDialog.tsx index b171be24..bbdcdea5 100644 --- a/src/components/AddMembersDialog.tsx +++ b/src/components/AddMembersDialog.tsx @@ -66,7 +66,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad try { await addToList.mutateAsync({ listId, pubkey: profile.pubkey }); setAddedPubkeys((prev) => new Set(prev).add(profile.pubkey)); - const name = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey); + const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey); toast({ title: `Added ${name} to list` }); } catch { toast({ title: 'Failed to add member', variant: 'destructive' }); @@ -142,7 +142,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad
) : ( filteredResults.map((profile, idx) => { - const name = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey); + const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey); const isAdding = addingPubkeys.has(profile.pubkey); const isAdded = addedPubkeys.has(profile.pubkey); const isSelected = idx === selectedIdx; diff --git a/src/components/AwardBadgeDialog.tsx b/src/components/AwardBadgeDialog.tsx index 8927a4dd..d803aaa1 100644 --- a/src/components/AwardBadgeDialog.tsx +++ b/src/components/AwardBadgeDialog.tsx @@ -113,7 +113,7 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
{selected.map((profile) => { - const name = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey); + const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey); return (
diff --git a/src/components/MediaCollage.tsx b/src/components/MediaCollage.tsx index 9af2cd5c..bab3c6d4 100644 --- a/src/components/MediaCollage.tsx +++ b/src/components/MediaCollage.tsx @@ -107,7 +107,7 @@ function AudioThumb({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const name = metadata?.name ?? genUserName(pubkey); + const name = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey); return (
diff --git a/src/components/MentionAutocomplete.tsx b/src/components/MentionAutocomplete.tsx index 5bb5231f..9e6eac44 100644 --- a/src/components/MentionAutocomplete.tsx +++ b/src/components/MentionAutocomplete.tsx @@ -297,7 +297,7 @@ function MentionItem({ onClick: () => void; }) { const { metadata, pubkey } = profile; - const displayName = metadata.display_name || metadata.name || genUserName(pubkey); + const displayName = metadata.name || metadata.display_name || genUserName(pubkey); const nip05 = metadata.nip05; const { data: nip05Verified } = useNip05Verify(nip05, pubkey); const nip05Display = nip05Verified && nip05 ? (nip05.startsWith('_@') ? nip05.slice(2) : nip05) : undefined; diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx index 31293df3..f18a6f98 100644 --- a/src/components/MobileDrawer.tsx +++ b/src/components/MobileDrawer.tsx @@ -100,8 +100,8 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) { const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); }; const handleLogout = async () => { await logout(); handleClose(); navigate('/'); }; - const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey); - const displayName = metadata?.name || (user ? genUserName(user.pubkey) : 'Anonymous'); + const getDisplayName = (account: Account) => account.metadata.name || account.metadata.display_name || genUserName(account.pubkey); + const displayName = metadata?.name || metadata?.display_name || (user ? genUserName(user.pubkey) : 'Anonymous'); return ( <> @@ -150,8 +150,8 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
- {currentUserEvent && metadata?.name - ? {metadata.name} + {currentUserEvent && (metadata?.name || metadata?.display_name) + ? {metadata.name || metadata.display_name || ''} : displayName} {metadata?.nip05 && ( @@ -290,7 +290,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) { className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-destructive hover:bg-destructive/10 transition-colors" > - Log out @{metadata?.name || genUserName(user.pubkey)} + Log out @{metadata?.name || metadata?.display_name || genUserName(user.pubkey)}
)} diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 4db10c87..0f54bf85 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -770,7 +770,7 @@ function SearchProfileItem({ onClick: (profile: SearchProfile) => void; }) { const { metadata, pubkey } = profile; - const displayName = metadata.display_name || metadata.name || genUserName(pubkey); + const displayName = metadata.name || metadata.display_name || genUserName(pubkey); const nip05 = metadata.nip05; const { data: nip05Verified } = useNip05Verify(nip05, pubkey); const nip05Display = nip05Verified && nip05 ? (nip05.startsWith('_@') ? nip05.slice(2) : nip05) : undefined; diff --git a/src/components/NostrEventSidebarItem.tsx b/src/components/NostrEventSidebarItem.tsx index 47ae6663..ca8aef1d 100644 --- a/src/components/NostrEventSidebarItem.tsx +++ b/src/components/NostrEventSidebarItem.tsx @@ -64,7 +64,7 @@ function ProfileSidebarLabel({ pubkey }: { pubkey: string }) { return ( - {metadata?.display_name || metadata?.name || genUserName(pubkey)} + {metadata?.name || metadata?.display_name || genUserName(pubkey)} ); } diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index c76c21f5..06f16caa 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1888,7 +1888,7 @@ export function EventActionHeader({ nounRoute, }: EventActionHeaderProps) { const author = useAuthor(pubkey); - const name = author.data?.metadata?.name || genUserName(pubkey); + const name = author.data?.metadata?.name || author.data?.metadata?.display_name || genUserName(pubkey); const url = useProfileUrl(pubkey, author.data?.metadata); return ( diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx index 9ad12dbf..9c510c1d 100644 --- a/src/components/NoteContent.tsx +++ b/src/components/NoteContent.tsx @@ -836,8 +836,8 @@ function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEv function NostrMention({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); - const hasRealName = !!author.data?.metadata?.name; - const displayName = author.data?.metadata?.name ?? genUserName(pubkey); + const hasRealName = !!(author.data?.metadata?.name || author.data?.metadata?.display_name); + const displayName = author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, author.data?.metadata); return ( diff --git a/src/components/NoteMoreMenu.tsx b/src/components/NoteMoreMenu.tsx index 4f0ef095..cb5cdf1d 100644 --- a/src/components/NoteMoreMenu.tsx +++ b/src/components/NoteMoreMenu.tsx @@ -316,7 +316,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o const pinned = isPinned(event.id); const isOwnPost = user?.pubkey === event.pubkey; const author = useAuthor(event.pubkey); - const displayName = author.data?.metadata?.name || genUserName(event.pubkey); + const displayName = author.data?.metadata?.name || author.data?.metadata?.display_name || genUserName(event.pubkey); const { addMute, removeMute, isMuted } = useMuteList(); const userMuted = isMuted('pubkey', event.pubkey); const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings(); diff --git a/src/components/PeopleAvatarStack.tsx b/src/components/PeopleAvatarStack.tsx index 9ca65479..5eb57542 100644 --- a/src/components/PeopleAvatarStack.tsx +++ b/src/components/PeopleAvatarStack.tsx @@ -67,7 +67,7 @@ export function PeopleAvatarStack({ {previewPubkeys.map((pk) => { const member = membersMap?.get(pk); const displayName = - member?.metadata?.display_name || member?.metadata?.name || genUserName(pk); + member?.metadata?.name || member?.metadata?.display_name || genUserName(pk); const shape = getAvatarShape(member?.metadata); return ( diff --git a/src/components/PollContent.tsx b/src/components/PollContent.tsx index 2d917dc7..1f1907f6 100644 --- a/src/components/PollContent.tsx +++ b/src/components/PollContent.tsx @@ -110,7 +110,7 @@ function VoterAvatarsButton({ const authorData = authorsMap?.get(vote.pubkey); const metadata = authorData?.metadata; const avatarShape = getAvatarShape(metadata); - const name = metadata?.name || genUserName(vote.pubkey); + const name = metadata?.name || metadata?.display_name || genUserName(vote.pubkey); return ( @@ -485,7 +485,7 @@ function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps) const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data; const metadata = authorData?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(vote.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(vote.pubkey); const nevent = useMemo( () => nip19.neventEncode({ id: vote.id, author: vote.pubkey }), diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 18c310be..b4d00012 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -126,7 +126,7 @@ export function ProfileCard({ const { refs: badgeRefs, isLoading: badgesLoading } = useProfileBadges(pubkey); const { badgeMap, isLoading: defsLoading } = useBadgeDefinitions(badgeRefs); - const displayName = metadata.display_name || metadata.name || genUserName(pubkey); + const displayName = metadata.name || metadata.display_name || genUserName(pubkey); const initial = displayName[0]?.toUpperCase() ?? '?'; const patch = (key: keyof NostrMetadata) => (v: string) => onChange?.({ [key]: v }); diff --git a/src/components/ProfileHoverCard.tsx b/src/components/ProfileHoverCard.tsx index fdb13bd1..ab36116f 100644 --- a/src/components/ProfileHoverCard.tsx +++ b/src/components/ProfileHoverCard.tsx @@ -36,7 +36,7 @@ function ProfileHoverCardBody({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name ?? genUserName(pubkey); + const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, metadata); const nip05 = metadata?.nip05; const nip05Domain = getNip05Domain(nip05); diff --git a/src/components/ProfileRecoveryDialog.tsx b/src/components/ProfileRecoveryDialog.tsx index 7c9cb11d..b361592e 100644 --- a/src/components/ProfileRecoveryDialog.tsx +++ b/src/components/ProfileRecoveryDialog.tsx @@ -92,7 +92,7 @@ function ProfileSnapshotCard({ isRestoring: boolean; }) { const metadata = useMemo(() => parseMetadata(event.content), [event.content]); - const displayName = metadata?.display_name || metadata?.name || genUserName(event.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(event.pubkey); const avatarShape = getAvatarShape(metadata); return ( diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 2ebee4d1..ff163ff4 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -912,7 +912,7 @@ function ProfileItem({ onClick: (profile: SearchProfile, profileUrl: string) => void; }) { const { metadata, pubkey } = profile; - const displayName = metadata.display_name || metadata.name || genUserName(pubkey); + const displayName = metadata.name || metadata.display_name || genUserName(pubkey); const nip05 = metadata.nip05; const { data: nip05Verified } = useNip05Verify(nip05, pubkey); const profileUrl = useProfileUrl(pubkey, metadata); diff --git a/src/components/RSVPAvatars.tsx b/src/components/RSVPAvatars.tsx index 1e8757d0..83fb3826 100644 --- a/src/components/RSVPAvatars.tsx +++ b/src/components/RSVPAvatars.tsx @@ -25,7 +25,7 @@ function RSVPAvatar({ pubkey, size = 'sm' }: { pubkey: string; size?: AvatarSize const metadata: NostrMetadata | undefined = data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey); const initial = displayName.charAt(0).toUpperCase(); return ( diff --git a/src/components/ReplyContext.tsx b/src/components/ReplyContext.tsx index ecad35f3..07059a6e 100644 --- a/src/components/ReplyContext.tsx +++ b/src/components/ReplyContext.tsx @@ -75,7 +75,7 @@ export function ReplyContext({ pubkeys, parentEventId, parentRelayHint, parentAu function ReplyAuthor({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); - const name = author.data?.metadata?.name || genUserName(pubkey); + const name = author.data?.metadata?.name || author.data?.metadata?.display_name || genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, author.data?.metadata); if (author.isLoading) { diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index 3a93921c..c61304b2 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -188,7 +188,7 @@ function HotPostCard({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(event.pubkey); const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]); const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`); @@ -226,14 +226,14 @@ function HotPostCard({ event }: { event: NostrEvent }) { } function LatestAccountCard({ event, onDismiss }: { event: NostrEvent; onDismiss: (pubkey: string) => void }) { - let metadata: { name?: string; nip05?: string; picture?: string } = {}; + let metadata: { name?: string; display_name?: string; nip05?: string; picture?: string } = {}; try { metadata = n.json().pipe(n.metadata()).parse(event.content); } catch { // Invalid metadata } - const displayName = metadata.name || genUserName(event.pubkey); + const displayName = metadata.name || metadata.display_name || genUserName(event.pubkey); const latestAvatarShape = getAvatarShape(metadata); const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); diff --git a/src/components/SavedFeedFiltersEditor.tsx b/src/components/SavedFeedFiltersEditor.tsx index 9cf50686..87e3b256 100644 --- a/src/components/SavedFeedFiltersEditor.tsx +++ b/src/components/SavedFeedFiltersEditor.tsx @@ -513,7 +513,7 @@ export function AuthorChip({ pubkey, onRemove }: { pubkey: string; onRemove: () try { const d = nip19.decode(pubkey); return d.type === 'npub' ? d.data : pubkey; } catch { return pubkey; } }, [pubkey]); const author = useAuthor(hexPubkey); - const name = author.data?.metadata?.display_name || author.data?.metadata?.name || pubkey.slice(0, 10) + '...'; + const name = author.data?.metadata?.name || author.data?.metadata?.display_name || pubkey.slice(0, 10) + '...'; const picture = author.data?.metadata?.picture; return ( @@ -532,7 +532,7 @@ export function AuthorChip({ pubkey, onRemove }: { pubkey: string; onRemove: () export function AuthorFilterDropdown({ onCommit }: { onCommit: (pubkey: string, _label: string) => void }) { const handleSelect = useCallback((profile: SearchProfile) => { - const label = profile.metadata.display_name || profile.metadata.name || profile.pubkey.slice(0, 16) + '...'; + const label = profile.metadata.name || profile.metadata.display_name || profile.pubkey.slice(0, 16) + '...'; onCommit(profile.pubkey, label); }, [onCommit]); diff --git a/src/components/TeamSoapboxCard.tsx b/src/components/TeamSoapboxCard.tsx index df01ac0c..b3f5166f 100644 --- a/src/components/TeamSoapboxCard.tsx +++ b/src/components/TeamSoapboxCard.tsx @@ -157,7 +157,7 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
{previewPubkeys.map((pk) => { const member = membersMap?.get(pk); - const name = member?.metadata?.name || genUserName(pk); + const name = member?.metadata?.name || member?.metadata?.display_name || genUserName(pk); const shape = getAvatarShape(member?.metadata); return ( diff --git a/src/components/ThemeUpdateCard.tsx b/src/components/ThemeUpdateCard.tsx index 6befb19a..cdaa1587 100644 --- a/src/components/ThemeUpdateCard.tsx +++ b/src/components/ThemeUpdateCard.tsx @@ -31,7 +31,7 @@ export function ThemeUpdateCard({ event }: ThemeUpdateCardProps) { const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); const authorEvent = author.data?.event; - const displayName = metadata?.name ?? genUserName(event.pubkey); + const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(event.pubkey); const profileUrl = useProfileUrl(event.pubkey, metadata); const theme = useMemo(() => parseThemeDefinition(event), [event]); diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx index 2ad40c8d..e33ff657 100644 --- a/src/components/auth/AccountSwitcher.tsx +++ b/src/components/auth/AccountSwitcher.tsx @@ -36,7 +36,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) { }; const getDisplayName = (account: Account): string => { - return account.metadata.name ?? genUserName(account.pubkey); + return account.metadata.name || account.metadata.display_name || genUserName(account.pubkey); } return ( diff --git a/src/components/letter/ComposeLetterSheet.tsx b/src/components/letter/ComposeLetterSheet.tsx index 8f7db484..1208efdd 100644 --- a/src/components/letter/ComposeLetterSheet.tsx +++ b/src/components/letter/ComposeLetterSheet.tsx @@ -93,7 +93,7 @@ const BODY_MAX_LENGTH = 220; function SelectedRecipient({ pubkey, onClear }: { pubkey: string; onClear?: () => void }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; - const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey); return (
@@ -308,8 +308,8 @@ export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProp }; const recipientAuthor = useAuthor(resolvedRecipient); - const recipientName = recipientAuthor.data?.metadata?.display_name - || recipientAuthor.data?.metadata?.name + const recipientName = recipientAuthor.data?.metadata?.name + || recipientAuthor.data?.metadata?.display_name || (resolvedRecipient ? genUserName(resolvedRecipient) : 'friend'); const resolvedSt = useMemo(() => resolveStationery(stationery ?? { color: DEFAULT_STATIONERY_COLOR }), [stationery]); diff --git a/src/components/letter/EnvelopeCard.tsx b/src/components/letter/EnvelopeCard.tsx index 624d62ba..1a16d08a 100644 --- a/src/components/letter/EnvelopeCard.tsx +++ b/src/components/letter/EnvelopeCard.tsx @@ -91,7 +91,7 @@ export function EnvelopeCard({ letter, mode, index, onClick, minimal }: Envelope const { data: decrypted } = useDecryptLetter(letter); const [moreMenuOpen, setMoreMenuOpen] = useState(false); - const displayName = author.data?.metadata?.name || genUserName(otherPubkey); + const displayName = author.data?.metadata?.name || author.data?.metadata?.display_name || genUserName(otherPubkey); const avatar = author.data?.metadata?.picture; const timeStr = shortTimeAgo(letter.timestamp); diff --git a/src/components/letter/LetterCard.tsx b/src/components/letter/LetterCard.tsx index 12ad80a8..00b052b2 100644 --- a/src/components/letter/LetterCard.tsx +++ b/src/components/letter/LetterCard.tsx @@ -54,7 +54,7 @@ export function LetterCard({ letter, mode }: LetterCardProps) { const queryClient = useQueryClient(); const shareOrigin = useShareOrigin(); - const displayName = author.data?.metadata?.name || genUserName(otherPubkey); + const displayName = author.data?.metadata?.name || author.data?.metadata?.display_name || genUserName(otherPubkey); const avatar = author.data?.metadata?.picture; const npub = nip19.npubEncode(otherPubkey); diff --git a/src/components/widgets/FeedWidget.tsx b/src/components/widgets/FeedWidget.tsx index 86f5d958..4812acf3 100644 --- a/src/components/widgets/FeedWidget.tsx +++ b/src/components/widgets/FeedWidget.tsx @@ -96,7 +96,7 @@ function CompactEventCard({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(event.pubkey); const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]); // Try to get a title from tags (articles, events, etc.) diff --git a/src/components/widgets/HotPostsWidget.tsx b/src/components/widgets/HotPostsWidget.tsx index 5b3934fa..dd81fa2e 100644 --- a/src/components/widgets/HotPostsWidget.tsx +++ b/src/components/widgets/HotPostsWidget.tsx @@ -63,7 +63,7 @@ function HotPostCard({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(event.pubkey); const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]); const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`); diff --git a/src/components/widgets/MusicWidget.tsx b/src/components/widgets/MusicWidget.tsx index 696c1da3..b77d1234 100644 --- a/src/components/widgets/MusicWidget.tsx +++ b/src/components/widgets/MusicWidget.tsx @@ -68,7 +68,7 @@ function MusicCard({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(event.pubkey); const parsed = useMemo(() => parseMusicTrack(event), [event]); const encodedId = useMemo(() => { diff --git a/src/components/widgets/PhotoWidget.tsx b/src/components/widgets/PhotoWidget.tsx index ae7e80c7..eabfc1c1 100644 --- a/src/components/widgets/PhotoWidget.tsx +++ b/src/components/widgets/PhotoWidget.tsx @@ -77,7 +77,7 @@ function PhotoCard({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(event.pubkey); const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]); const photo = useMemo(() => parseFirstPhoto(event.tags), [event.tags]); diff --git a/src/hooks/useSearchProfiles.ts b/src/hooks/useSearchProfiles.ts index badc6037..97befcba 100644 --- a/src/hooks/useSearchProfiles.ts +++ b/src/hooks/useSearchProfiles.ts @@ -46,8 +46,8 @@ function searchCachedProfiles( const aFollowed = followedPubkeys.has(a.pubkey) ? 0 : 1; const bFollowed = followedPubkeys.has(b.pubkey) ? 0 : 1; if (aFollowed !== bFollowed) return aFollowed - bFollowed; - const aName = (a.metadata.display_name || a.metadata.name || '').toLowerCase(); - const bName = (b.metadata.display_name || b.metadata.name || '').toLowerCase(); + const aName = (a.metadata.name || a.metadata.display_name || '').toLowerCase(); + const bName = (b.metadata.name || b.metadata.display_name || '').toLowerCase(); return aName.localeCompare(bName); }); diff --git a/src/hooks/useWebxdc.ts b/src/hooks/useWebxdc.ts index 60bdea28..923b1596 100644 --- a/src/hooks/useWebxdc.ts +++ b/src/hooks/useWebxdc.ts @@ -115,7 +115,7 @@ export function useWebxdc(uuid: string): WebxdcAPI { const activePubkey = user ? user.pubkey : ephemeralPubkey; const selfAddr = nip19.npubEncode(activePubkey); - const selfName = metadata?.display_name || metadata?.name || nip19.npubEncode(activePubkey).slice(0, 12); + const selfName = metadata?.name || metadata?.display_name || nip19.npubEncode(activePubkey).slice(0, 12); // Publish a signed event using whichever signer is active (logged-in user or ephemeral key) const publishSigned = useCallback(async (template: Parameters[0]) => { diff --git a/src/lib/getDisplayName.ts b/src/lib/getDisplayName.ts index 6ec92e74..2a8808d1 100644 --- a/src/lib/getDisplayName.ts +++ b/src/lib/getDisplayName.ts @@ -3,13 +3,14 @@ import type { NostrMetadata } from '@nostrify/nostrify'; /** * Get a display name for a user. - * Uses metadata.name if available, otherwise generates a deterministic username. - * Visual truncation is handled by CSS (`truncate` class) on the containing element - * to avoid breaking NIP-30 custom emoji shortcodes. + * Prefers metadata.name, falls back to metadata.display_name, then a + * deterministic generated username. Visual truncation is handled by CSS + * (`truncate` class) on the containing element to avoid breaking NIP-30 + * custom emoji shortcodes. */ export function getDisplayName( metadata: NostrMetadata | undefined, pubkey: string, ): string { - return metadata?.name || genUserName(pubkey); + return metadata?.name || metadata?.display_name || genUserName(pubkey); } diff --git a/src/pages/BadgesPage.tsx b/src/pages/BadgesPage.tsx index 46af14a5..8d7d6ba2 100644 --- a/src/pages/BadgesPage.tsx +++ b/src/pages/BadgesPage.tsx @@ -1123,7 +1123,7 @@ function FollowsFeedTab({ onRefresh }: { onRefresh: () => Promise }) { function IssuerName({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); - const name = author.data?.metadata?.name ?? genUserName(pubkey); + const name = author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey); return ( by {name} ); diff --git a/src/pages/FollowPage.tsx b/src/pages/FollowPage.tsx index e6dce676..9babd9f8 100644 --- a/src/pages/FollowPage.tsx +++ b/src/pages/FollowPage.tsx @@ -182,7 +182,7 @@ function FollowView({ pubkey }: { pubkey: string }) { const { toast } = useToast(); const navigate = useNavigate(); const metadata = author.data?.metadata; - const displayName = metadata?.name || genUserName(pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, metadata); const bannerUrl = metadata?.banner; const { startSignup } = useOnboarding(); @@ -371,7 +371,7 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[] const author = useAuthor(addr.pubkey); const authorMeta = author.data?.metadata; - const authorName = authorMeta?.name || genUserName(addr.pubkey); + const authorName = authorMeta?.name || authorMeta?.display_name || genUserName(addr.pubkey); const { title, description, image, pubkeys } = useMemo( () => (event ? parsePackEvent(event) : { title: 'Loading...', description: '', image: undefined, pubkeys: [] }), @@ -490,7 +490,7 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[]
{pubkeys.slice(0, 5).map((pk) => { const member = membersMap?.get(pk); - const name = member?.metadata?.name || genUserName(pk); + const name = member?.metadata?.name || member?.metadata?.display_name || genUserName(pk); const shape = getAvatarShape(member?.metadata); return ( diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 745dbb22..d7ba3199 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -367,7 +367,7 @@ function ActorAvatars({ actors }: { actors: NotificationItem[] }) { function ActorAvatar({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; - const name = metadata?.name ?? genUserName(pubkey); + const name = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, metadata); const shape = getAvatarShape(metadata); const isEmojiShape = !!shape; @@ -446,7 +446,7 @@ function GroupHeader({ /** Helper hook to get a display name for a pubkey. */ function useActorName(pubkey: string): string { const author = useAuthor(pubkey || 'dummy'); - return author.data?.metadata?.name ?? genUserName(pubkey || 'dummy'); + return author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey || 'dummy'); } function ActorLink({ pubkey, name }: { pubkey: string; name: string }) { @@ -913,7 +913,7 @@ function NotificationHeader({ }) { const author = useAuthor(actorPubkey); const metadata = author.data?.metadata; - const displayName = metadata?.name || genUserName(actorPubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(actorPubkey); const profileUrl = useProfileUrl(actorPubkey, metadata); if (author.isLoading) { diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index b2a98037..57f858a1 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -412,7 +412,7 @@ function FollowingUserRow({ pubkey, onNavigate }: { pubkey: string; onNavigate?: const author = useAuthor(pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey); const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); return ( @@ -1335,7 +1335,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; } }, [pubkey, queryClient]); const metadataEvent = author.data?.event; - const displayName = metadata?.name || (pubkey ? genUserName(pubkey) : 'Anonymous'); + const displayName = metadata?.name || metadata?.display_name || (pubkey ? genUserName(pubkey) : 'Anonymous'); // Kind 3 + 10001 — fetched separately so the large contact list // doesn't block the profile header or feed from rendering. diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 805fef98..d65b7d43 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -861,8 +861,8 @@ export function SearchPage() { function AccountItem({ profile, isFollowed }: { profile: { pubkey: string; metadata: Record; event?: { tags: string[][] } }; isFollowed: boolean }) { const npub = useMemo(() => nip19.npubEncode(profile.pubkey), [profile.pubkey]); - const metadata = profile.metadata as { name?: string; nip05?: string; picture?: string; about?: string; bot?: boolean }; - const displayName = metadata?.name || genUserName(profile.pubkey); + const metadata = profile.metadata as { name?: string; display_name?: string; nip05?: string; picture?: string; about?: string; bot?: boolean }; + const displayName = metadata?.name || metadata?.display_name || genUserName(profile.pubkey); const profileAvatarShape = getAvatarShape(metadata); const tags = profile.event?.tags ?? []; @@ -949,7 +949,7 @@ function FollowItem({ pubkey }: { pubkey: string }) { const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); - const displayName = metadata?.name || genUserName(pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey); const tags = author.data?.event?.tags ?? []; if (author.isLoading) { diff --git a/src/pages/UserListsPage.tsx b/src/pages/UserListsPage.tsx index 8b71697b..987c95e0 100644 --- a/src/pages/UserListsPage.tsx +++ b/src/pages/UserListsPage.tsx @@ -42,7 +42,7 @@ function MiniAvatar({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name ?? genUserName(pubkey); + const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey); return ( From b2634d2fcbc6984b64c4a4d327020af79d0e76e0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Apr 2026 01:44:15 -0500 Subject: [PATCH 06/22] Render Birdstar Birdex events (kind 12473) as tiled life lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Birdex is a replaceable per-author index of every bird species the author has ever confirmed via kind 2473, stored as positional i/n tag pairs in chronological first-seen order. In feeds, show a compact tile strip of the most recently-added species with a "+N" capstone when the list overflows — mirroring how kind 3 follow lists preview members as an avatar stack plus "+N more". On the post-detail page, render every species as a responsive grid so visitors can browse the author's whole life list. Each tile resolves the species' Wikidata entity through English Wikipedia to pull a thumbnail and common-name label, reusing the same fetch path as kind 2473 detection cards. The Wikidata URL is sanitized before being routed, and the paired n tag provides a scientific-name fallback while the remote lookup is in flight. --- NIP.md | 4 +- src/App.tsx | 1 + src/components/BirdexContent.tsx | 139 +++++++++++++++++++++++ src/components/BirdexTile.tsx | 114 +++++++++++++++++++ src/components/CommentContext.tsx | 2 + src/components/ExternalContentHeader.tsx | 2 + src/components/NoteCard.tsx | 5 + src/contexts/AppContext.ts | 2 + src/lib/extraKinds.ts | 13 +++ src/lib/feedUtils.ts | 7 ++ src/lib/parseBirdex.ts | 63 ++++++++++ src/lib/schemas.ts | 1 + src/pages/NotificationsPage.tsx | 1 + src/pages/PostDetailPage.tsx | 5 + src/test/TestApp.tsx | 1 + 15 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 src/components/BirdexContent.tsx create mode 100644 src/components/BirdexTile.tsx create mode 100644 src/lib/parseBirdex.ts diff --git a/NIP.md b/NIP.md index d4cb8996..7c0a1c53 100644 --- a/NIP.md +++ b/NIP.md @@ -18,6 +18,7 @@ These event kinds were created by community contributors and are supported by Di | Kind | Name | Description | Spec | |-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------| | 2473 | Bird Detection | Bird-by-ear observation log (species heard in the wild) | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) | +| 12473 | Birdex | Author's cumulative life list of confirmed bird species | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) | | 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) | | 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) | | 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) | @@ -427,7 +428,7 @@ The following specifications are maintained by their respective authors. Ditto i Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion. -### Birdstar (Kinds 2473, 30621) +### Birdstar (Kinds 2473, 12473, 30621) **Author:** Alex Gleason **Spec:** https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md @@ -436,6 +437,7 @@ Color palette posts capturing 3-6 colors from a beautiful moment, optionally acc Birdstar merges Birdsong Spotter (a bird-by-ear checklist) and Starpoint (an interactive sky map with community constellations) into a single client. - **Kind 2473 — Bird Detection.** A regular event representing a single identified bird observation. The species is identified by a NIP-73 `i`/`k` pair pointing at the species' Wikidata entity URI (e.g. `https://www.wikidata.org/entity/Q26825` for the American Robin). The `content` field holds an optional freeform human note about the detection. Required tags: NIP-31 `alt`, NIP-73 `i` (Wikidata URL) + `k` (`web`). Ditto renders detections as a species card with the Wikipedia thumbnail, common/scientific name, and article summary. +- **Kind 12473 — Birdex.** A replaceable event (one per author) indexing every distinct species the author has ever confirmed via kind 2473. Each species is a positional `i`/`n` pair — the Wikidata entity URI followed immediately by the scientific binomial name — emitted in chronological order of first detection. Ditto renders a Birdex as a tiled grid of species, each tile showing the Wikipedia thumbnail with the common name overlaid. In feeds, only the most recent few tiles are shown with a "+N" capstone mirroring how kind 3 follow lists preview members; the post-detail page shows every species. - **Kind 30621 — Custom Constellation.** An addressable event (`d` tag) representing a single user-drawn star figure. Each `edge` tag (`["edge", from, to]`) references two Hipparcos catalog numbers as decimal strings — e.g. `["edge", "32349", "37279"]` for Sirius → Procyon. Required tags: `d`, `title`, `alt`, and at least one valid `edge`. The `content` field is a freeform description. Ditto renders constellations as a stylized SVG star-map (gnomonically projected onto a tangent plane at the figure's centroid, with stars sized by magnitude) using a bundled Hipparcos catalog that is code-split so the data only loads when a constellation is actually viewed. ### Geocaching (Kinds 37516, 7516) diff --git a/src/App.tsx b/src/App.tsx index f5c80a90..550e8568 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -114,6 +114,7 @@ const hardcodedConfig: AppConfig = { feedIncludeBlobbi: true, showBirdstar: true, feedIncludeBirdDetections: true, + feedIncludeBirdex: true, feedIncludeConstellations: true, followsFeedShowReplies: true, }, diff --git a/src/components/BirdexContent.tsx b/src/components/BirdexContent.tsx new file mode 100644 index 00000000..c91e0877 --- /dev/null +++ b/src/components/BirdexContent.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react'; +import { Bird } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { BirdexTile } from '@/components/BirdexTile'; +import { parseBirdexEvent } from '@/lib/parseBirdex'; +import { cn } from '@/lib/utils'; + +/** + * Birdstar kind 12473 — Birdex (life list). + * + * A replaceable per-author index of every distinct bird species the + * author has ever logged via kind 2473. Each species is a positional + * `i`/`n` pair (Wikidata entity URI + scientific name), emitted in + * chronological order of first detection. + * + * Feed variant: a small tiled preview of the most recently-added + * species plus a "+N" capstone, mirroring how kind 3 follow lists + * render as a compact avatar stack with a "+N more" suffix. Full + * variant: the whole life list laid out as a responsive grid so + * visitors can browse every species the author has ever seen. + */ + +/** Tiles rendered in the compact feed preview before collapsing into "+N". */ +const FEED_PREVIEW_LIMIT = 8; + +interface BirdexContentProps { + event: NostrEvent; + /** + * When true, render every species on the life list instead of the + * truncated feed preview. Used on the post-detail page. + */ + expanded?: boolean; + className?: string; +} + +export function BirdexContent({ event, expanded, className }: BirdexContentProps) { + const entries = useMemo(() => parseBirdexEvent(event), [event]); + + // Empty Birdex — either a malformed event or a newly-published + // placeholder. Render a minimal dashed card so the feed row still + // has a meaningful anchor. + if (entries.length === 0) { + return ( +
+ + Empty Birdex — no confirmed species yet. +
+ ); + } + + if (expanded) { + return ( +
+
+ +

+ Birdex + + {entries.length} species + +

+
+ +
+ {entries.map((entry) => ( + + ))} +
+
+ ); + } + + // Feed variant — show the *most recent* species (tail of the list) + // so the preview reflects the author's latest additions, with an + // overflow capstone on the final tile when the Birdex is larger + // than the preview. The capstone displaces one species slot, so + // when overflowing we render (LIMIT - 1) real tiles + the capstone; + // the capstone's count is "species not shown", which includes the + // one species the capstone itself displaced. + const overflowing = entries.length > FEED_PREVIEW_LIMIT; + const visibleSpeciesCount = overflowing ? FEED_PREVIEW_LIMIT - 1 : entries.length; + const previewEntries = entries.slice(-visibleSpeciesCount); + const overflowCount = entries.length - visibleSpeciesCount; + + return ( +
+
+ + + Birdex + + + · {entries.length} species + +
+ +
+ {previewEntries.map((entry) => ( + + ))} + {overflowing && } +
+
+ ); +} + +/** + * Final capstone tile that reads "+N" when the life list overflows + * the feed preview. Mirrors the "+N more" suffix on kind 3 follow-list + * avatar stacks. + */ +function OverflowTile({ count }: { count: number }) { + return ( +
+ + +{count} + +
+ ); +} diff --git a/src/components/BirdexTile.tsx b/src/components/BirdexTile.tsx new file mode 100644 index 00000000..67444bf1 --- /dev/null +++ b/src/components/BirdexTile.tsx @@ -0,0 +1,114 @@ +import { Bird } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +import { Skeleton } from '@/components/ui/skeleton'; +import { useWikidataEntity } from '@/hooks/useWikidataEntity'; +import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; + +/** + * A single tile in a Birdex grid — one species. + * + * Resolves Wikidata → English Wikipedia to pull a thumbnail and common + * name. The scientific name (optional, from the paired `n` tag on the + * Birdex event) is used as a fallback label while the remote fetch is + * in flight or fails. + * + * Clicking the tile routes to Ditto's external-content page for the + * species' Wikidata URL, so the species page aggregates detections, + * comments, and other Birdex authors who have this species on their + * life lists — the same landing spot used by kind 2473 bird-detection + * cards. + */ +interface BirdexTileProps { + entityUri: string; + entityId: string; + /** Optional scientific name from the paired `n` tag. */ + scientificName?: string; + /** Extra classes applied to the tile container. */ + className?: string; + /** Drop the navigation link (used by disabled-hover embeds). */ + nonInteractive?: boolean; +} + +export function BirdexTile({ + entityUri, + entityId, + scientificName, + className, + nonInteractive, +}: BirdexTileProps) { + const { data: entity, isLoading: entityLoading } = useWikidataEntity(entityId); + const wikipediaTitle = entity?.wikipediaTitle ?? null; + const { data: summary, isLoading: summaryLoading } = useWikipediaSummary(wikipediaTitle); + + const isLoading = entityLoading || summaryLoading; + + // Prefer the Wikipedia page title for the display label; fall back to + // the scientific name from the Birdex's `n` tag while fetches are in + // flight or when no English article exists. + const commonName = summary?.title ?? (scientificName || 'Unknown species'); + const thumbnail = sanitizeUrl(summary?.thumbnail?.source); + + const inner = ( +
+ {isLoading ? ( + + ) : thumbnail ? ( + {commonName} + ) : ( +
+ +
+ )} + + {/* Name overlay — always rendered, even during skeleton, so the + tile's shape is stable. */} +
+
+

+ {isLoading && !scientificName ? '\u00A0' : commonName} +

+ {scientificName && summary?.title && summary.title !== scientificName && ( +

+ {scientificName} +

+ )} +
+
+
+ ); + + if (nonInteractive) return inner; + + return ( + e.stopPropagation()} + className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl" + aria-label={commonName} + > + {inner} + + ); +} diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index d858a1a7..cd6c3b8f 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -118,6 +118,7 @@ const KIND_LABELS: Record = { 1617: 'a patch', 1618: 'a pull request', 2473: 'a bird detection', + 12473: 'a Birdex', 3367: 'a color moment', 7516: 'a found log', 15128: 'an nsite', @@ -204,6 +205,7 @@ const KIND_ICONS: Partial = { 35128: 'Nsite', 31124: 'Blobbi', 2473: 'Bird Detection', + 12473: 'Birdex', 30621: 'Constellation', }; @@ -1256,6 +1257,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey if (addr.kind === 15128 || addr.kind === 35128) return Globe; if (addr.kind === 3 || addr.kind === 30000) return Users; if (addr.kind === 2473) return Bird; + if (addr.kind === 12473) return Bird; if (addr.kind === 30621) return Stars; return FileText; }, [kindDef, addr.kind]); diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 06f16caa..3b88f26a 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -58,6 +58,7 @@ import { PeopleListContent } from "@/components/PeopleListContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { BirdDetectionContent } from "@/components/BirdDetectionContent"; +import { BirdexContent } from "@/components/BirdexContent"; import { ConstellationContent } from "@/components/ConstellationContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { NsiteCard } from "@/components/NsiteCard"; @@ -390,6 +391,7 @@ export const NoteCard = memo(function NoteCard({ const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; const isBirdDetection = event.kind === 2473; + const isBirdex = event.kind === 12473; const isConstellation = event.kind === 30621; const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isArticle = event.kind === 30023; @@ -440,6 +442,7 @@ export const NoteCard = memo(function NoteCard({ !isFoundLog && !isColor && !isBirdDetection && + !isBirdex && !isConstellation && !isPeopleList && !isArticle && @@ -595,6 +598,8 @@ export const NoteCard = memo(function NoteCard({ ) : isBirdDetection ? ( + ) : isBirdex ? ( + ) : isConstellation ? ( ) : isPeopleList ? ( diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index fca32852..47b05406 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -158,6 +158,8 @@ export interface FeedSettings { showBirdstar: boolean; /** Include bird detections (kind 2473) in the follows/global feed */ feedIncludeBirdDetections: boolean; + /** Include Birdex life lists (kind 12473) in the follows/global feed */ + feedIncludeBirdex: boolean; /** Include custom constellations (kind 30621) in the follows/global feed */ feedIncludeConstellations: boolean; /** Include replies in the follows feed (default: true) */ diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 81b20e85..48f3cf0e 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -496,6 +496,18 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ blurb: 'Bird-by-ear detections — someone heard a species sing or call, and logged the sighting. Identified by Wikidata entity.', sites: [{ url: 'https://birdstar.app', name: 'Birdstar' }], }, + { + kind: 12473, + id: 'birdex', + feedKey: 'feedIncludeBirdex', + label: 'Birdex', + description: 'Cumulative life list of every species a user has ever identified', + addressable: false, + section: 'whimsy', + feedOnly: true, + blurb: 'Birdex — an author\'s cumulative life list of every species they have ever identified, in chronological order of first detection.', + sites: [{ url: 'https://birdstar.app', name: 'Birdstar' }], + }, { kind: 30621, id: 'constellations', @@ -604,6 +616,7 @@ const KIND_SPECIFIC_ICONS: Partial n === 'i' && typeof v === 'string' && wikidataRe.test(v))) return true; } + // Birdex life lists (kind 12473) with no valid species entries — a + // Birdex is an index over the author's kind 2473 detections, so one + // with zero parseable `i` tags has nothing to show. + if (event.kind === 12473) { + const wikidataRe = /^https:\/\/www\.wikidata\.org\/entity\/Q\d+$/; + if (!event.tags.some(([n, v]) => n === 'i' && typeof v === 'string' && wikidataRe.test(v))) return true; + } // Custom constellations (kind 30621) without any valid edge tags if (event.kind === 30621) { const hasEdge = event.tags.some(([n, from, to]) => n === 'edge' && /^\d+$/.test(from ?? '') && /^\d+$/.test(to ?? '')); diff --git a/src/lib/parseBirdex.ts b/src/lib/parseBirdex.ts new file mode 100644 index 00000000..f18f2862 --- /dev/null +++ b/src/lib/parseBirdex.ts @@ -0,0 +1,63 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Canonical Wikidata entity URI shape used by Birdstar kinds 2473 and 12473. + * https → www.wikidata.org → /entity/Q — no fragment, query, or + * trailing slash. This is the trust boundary; tags that don't match are + * silently skipped. + */ +const WIKIDATA_ENTITY_URI_RE = /^https:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/; + +/** A single species entry on a Birdex life list. */ +export interface BirdexSpeciesEntry { + /** Wikidata entity URI — the canonical species identifier. */ + entityUri: string; + /** Wikidata entity ID (e.g. "Q26825") parsed from the URI. */ + entityId: string; + /** + * Scientific (binomial) name carried by the positionally-paired `n` + * tag. Empty string when the source event omitted the `n` tag (older + * Birdstar events, or events authored by tools predating the + * name-pairing convention). + */ + scientificName: string; +} + +/** + * Walk the tags of a kind 12473 Birdex event in order, pairing each + * valid `i` tag with the immediately-following `n` tag (if present) + * per Birdstar NIP § "Kind 12473 — Birdex". + * + * Pairing is positional: the `n` tag is accepted as this species' + * scientific name only when it is the very next entry in the tag + * array. An `i` tag not followed by an `n` yields an entry with an + * empty `scientificName` — still renderable by Q-id alone. + * + * Deduplication keeps the first occurrence of each URI so the + * chronological first-seen order is preserved even if a malformed + * publisher emits duplicates. The URL-shape regex is the trust + * boundary — no paired `k` tag is consulted (the kind contract + * already guarantees every valid `i` is a Wikidata entity URI). + */ +export function parseBirdexEvent(event: NostrEvent): BirdexSpeciesEntry[] { + const seen = new Set(); + const entries: BirdexSpeciesEntry[] = []; + const tags = event.tags; + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + if (tag[0] !== 'i') continue; + const uri = tag[1]; + if (typeof uri !== 'string') continue; + const m = uri.match(WIKIDATA_ENTITY_URI_RE); + if (!m) continue; + if (seen.has(uri)) continue; + seen.add(uri); + + const next = tags[i + 1]; + const scientificName = + next && next[0] === 'n' && typeof next[1] === 'string' ? next[1] : ''; + + entries.push({ entityUri: uri, entityId: m[1], scientificName }); + } + return entries; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index a7340dc2..718d3145 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -185,6 +185,7 @@ export const FeedSettingsSchema = z.looseObject({ feedIncludeBadgeAwards: z.boolean().optional(), showBirdstar: z.boolean().optional(), feedIncludeBirdDetections: z.boolean().optional(), + feedIncludeBirdex: z.boolean().optional(), feedIncludeConstellations: z.boolean().optional(), }); diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index d7ba3199..eb3c4e8b 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -69,6 +69,7 @@ const NOTIFICATION_KIND_NOUNS: Record = { 1617: 'patch', 1618: 'pull request', 2473: 'bird detection', + 12473: 'Birdex', 3367: 'color moment', 7516: 'found log', 15128: 'nsite', diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 8f9d2cec..d66e306f 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -46,6 +46,7 @@ import { PeopleListDetailContent } from "@/components/PeopleListDetailContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { BirdDetectionContent } from "@/components/BirdDetectionContent"; +import { BirdexContent } from "@/components/BirdexContent"; import { ConstellationContent } from "@/components/ConstellationContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { ImageGallery } from "@/components/ImageGallery"; @@ -164,6 +165,7 @@ function shellTitleForKind(kind?: number): string { if (kind === 0) return "Profile"; if (kind === 31124) return "Blobbi"; if (kind === 2473) return "Bird Detection"; + if (kind === 12473) return "Birdex"; if (kind === 30621) return "Constellation"; return "Post Details"; } @@ -1019,6 +1021,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; const isBirdDetection = event.kind === 2473; + const isBirdex = event.kind === 12473; const isConstellation = event.kind === 30621; const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isEmojiPack = event.kind === 30030; @@ -2199,6 +2202,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { isFoundLog || isColor || isBirdDetection || + isBirdex || isConstellation || isPeopleList || isEmojiPack ? ( @@ -2209,6 +2213,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {isFoundLog && } {isColor && } {isBirdDetection && } + {isBirdex && } {isConstellation && } {isPeopleList && } {isEmojiPack && } diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index a3641877..b702a99d 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -94,6 +94,7 @@ export function TestApp({ children }: TestAppProps) { feedIncludeBlobbi: true, showBirdstar: false, feedIncludeBirdDetections: false, + feedIncludeBirdex: false, feedIncludeConstellations: false, followsFeedShowReplies: true, }, From 7eb70f3a61edb91b6c04c69a90be4085e927284d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Apr 2026 01:52:01 -0500 Subject: [PATCH 07/22] Prefer the n tag for scientific name on Bird Detection cards Birdstar kind 2473 events now carry the species' scientific binomial name in an authoritative 'n' tag, added to the NIP so clients can label a detection without round-tripping Wikidata. Ditto was still scraping the name out of the 'alt' tag's parenthetical, which loses the label when the publisher emits a bare alt like "Bird detection". Prefer the 'n' tag and fall back to 'alt' parsing only for older events authored before the tag was part of the NIP. Also show the scientific name as a persistent italic sub-label on Birdex tiles, matching how detection cards stack the two labels. Regression-of: b2634d2f --- src/components/BirdDetectionContent.tsx | 18 +++++++++++++++++- src/components/BirdexTile.tsx | 8 ++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/BirdDetectionContent.tsx b/src/components/BirdDetectionContent.tsx index 906aa744..f3684427 100644 --- a/src/components/BirdDetectionContent.tsx +++ b/src/components/BirdDetectionContent.tsx @@ -51,9 +51,26 @@ function extractAltSpecies(tags: string[][]): { common?: string; scientific?: st return { common: m[1]?.trim(), scientific: m[2]?.trim() }; } +/** Extract the scientific name from the `n` tag (Birdstar NIP §"Kind 2473" — the + * authoritative scientific-name field, added so clients can label a detection + * without round-tripping Wikidata). Falls through to parsing the `alt` tag for + * older events authored before `n` was part of the NIP. */ +function extractScientificName( + tags: string[][], + altScientific: string | undefined, +): string | undefined { + const n = tags.find(([name]) => name === 'n')?.[1]; + if (typeof n === 'string' && n.trim()) return n.trim(); + return altScientific; +} + export function BirdDetectionContent({ event, className }: BirdDetectionContentProps) { const wikidata = useMemo(() => extractWikidata(event.tags), [event.tags]); const altSpecies = useMemo(() => extractAltSpecies(event.tags), [event.tags]); + const scientificName = useMemo( + () => extractScientificName(event.tags, altSpecies?.scientific), + [event.tags, altSpecies?.scientific], + ); const note = event.content.trim(); // Resolve Wikidata → English Wikipedia title, then fetch the Wikipedia @@ -68,7 +85,6 @@ export function BirdDetectionContent({ event, className }: BirdDetectionContentP // but fall back to the species parsed from the `alt` tag so the card is // still meaningful while the Wikipedia fetch is in flight (or has failed). const commonName = summary?.title ?? altSpecies?.common ?? 'Unknown species'; - const scientificName = altSpecies?.scientific; const extract = summary?.extract; const thumbnail = sanitizeUrl(summary?.thumbnail?.source); const articleUrl = sanitizeUrl(summary?.articleUrl); diff --git a/src/components/BirdexTile.tsx b/src/components/BirdexTile.tsx index 67444bf1..e60919bd 100644 --- a/src/components/BirdexTile.tsx +++ b/src/components/BirdexTile.tsx @@ -83,13 +83,17 @@ export function BirdexTile({ )} {/* Name overlay — always rendered, even during skeleton, so the - tile's shape is stable. */} + tile's shape is stable. Common name on top (from Wikipedia + when available, scientific fallback otherwise); scientific + name from the Birdex's paired `n` tag as a persistent + italic sub-label underneath, mirroring how kind 2473 + detection cards stack the two labels. */}

{isLoading && !scientificName ? '\u00A0' : commonName}

- {scientificName && summary?.title && summary.title !== scientificName && ( + {scientificName && scientificName !== commonName && (

{scientificName}

From 9a34fa010240d4bbd54eb1b41f3b85e53743c9c2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 30 Apr 2026 01:57:51 -0500 Subject: [PATCH 08/22] Play bird-song recordings on Wikipedia species pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wikipedia/Wikimedia Commons hosts editorially-curated, mostly Xeno- Canto-sourced recordings on bird species articles. Surface them on Ditto's /i/ page whenever the article exposes one: an emerald play button sits inline with the article title, looping the song on click and swapping its play triangle for an animated equaliser to indicate active playback. When a recording is present, the footer row gains a second link crediting the recordist and license and pointing at the Commons file-description page for verification. Adapted from Birdstar's BirdInfoDialog / useBirdSound / useWikipedia Sound hooks; the iNaturalist fallback from the original is dropped per request — Ditto only uses Wikipedia/Commons. --- src/components/BirdSongPlayer.tsx | 181 ++++++++++++++++++ src/components/ExternalContentHeader.tsx | 55 +++++- src/hooks/useBirdSong.ts | 228 +++++++++++++++++++++++ tailwind.config.ts | 11 +- 4 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 src/components/BirdSongPlayer.tsx create mode 100644 src/hooks/useBirdSong.ts diff --git a/src/components/BirdSongPlayer.tsx b/src/components/BirdSongPlayer.tsx new file mode 100644 index 00000000..19e131e9 --- /dev/null +++ b/src/components/BirdSongPlayer.tsx @@ -0,0 +1,181 @@ +import { useEffect, useRef, useState } from 'react'; +import { Play } from 'lucide-react'; + +import { Skeleton } from '@/components/ui/skeleton'; +import { useBirdSong, type BirdSong } from '@/hooks/useBirdSong'; +import { cn } from '@/lib/utils'; + +/** + * Inline bird-song play button for Wikipedia species pages. + * + * Looks up a reference recording from the article (via + * `useBirdSong` → Wikipedia/Commons) and renders a circular + * toggle. Clicking plays the song on loop; the play triangle is + * replaced by an animated equaliser so the single control both + * triggers and indicates playback. The `
- {/* Title */} -

- {wiki.title} -

+ {/* Title row with inline bird-song player. The player is + lazily resolved against the Wikipedia article — it + returns `null` for pages with no field-recording audio + (the vast majority of non-bird pages), so non-species + articles just render the plain title. For bird species + pages the button renders as an emerald circular control + that toggles playback of a Wikimedia Commons recording + and is visually anchored inline with the title so the + eye takes the two as one unit. */} +
+

+ {wiki.title} +

+ +
{/* Description */} {wiki.description && ( @@ -460,8 +483,15 @@ function WikipediaArticleHeader({ title, url }: { title: string; url: string }) )}
- {/* Footer with Wikipedia link */} -
+ {/* Footer with Wikipedia link — plus a song-attribution link + when the article had a usable Commons recording. Commons + licenses require visible attribution; we surface it here as + a second inline link sharing the footer row with the + "Read on Wikipedia" link rather than inventing a new strip + just for the song credit. `title={song.attribution}` gives + hover/focus users the full attribution string when it's + truncated on narrow viewports. */} +
); diff --git a/src/hooks/useBirdSong.ts b/src/hooks/useBirdSong.ts new file mode 100644 index 00000000..8162767a --- /dev/null +++ b/src/hooks/useBirdSong.ts @@ -0,0 +1,228 @@ +import { useQuery } from '@tanstack/react-query'; + +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +/** + * Resolve a reference recording from a Wikipedia article, for use on + * species pages (bird species in particular, but the approach is + * generic — any Wikipedia article with an embedded non-spoken audio + * file will work). + * + * Adapted from Birdstar's `useBirdSound` / `useWikipediaSound` hooks + * (see birdstar/src/hooks/useWikipediaSound.ts for the original). + * Birdstar falls back to iNaturalist for obscure species whose enwiki + * article lacks a recording; we deliberately skip that fallback per + * the user's request — Ditto only uses Wikipedia/Commons. + * + * Why Wikipedia? Bird species articles on enwiki carry editorially + * curated, mostly Xeno-Canto-sourced recordings: clean, labeled, + * single-species. The REST `page/media-list/{title}` endpoint lists + * every media file on the article tagged by type; we pick the first + * non-"spoken" audio item (spoken = Wikipedia spoken-article + * narrations of the prose, which we obviously don't want). Then the + * action API `prop=videoinfo` returns the file's URL plus MediaWiki's + * auto-generated MP3 transcode of OGG sources, which we prefer for + * Safari/iOS `