diff --git a/CHANGELOG.md b/CHANGELOG.md index af730f17..242f15fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [2.12.1] - 2026-05-01 + +### Changed +- The right widget sidebar now appears on iPad-landscape (1024px) viewports, and both sidebars scale fluidly with the window instead of snapping at fixed widths +- Hashtags with internal hyphens like `#bitcoin-conference` and `#70-706` now render as a single tag instead of being cut off at the dash + +### Fixed +- Comments on country, book, and Bitcoin transaction/address pages now load correctly instead of showing an empty thread + +## [2.12.0] - 2026-04-30 + +### Added +- Bitcoin wallet -- a new Wallet tab in the sidebar shows your balance in USD with BTC underneath, a transaction history that collapses when empty, and a 3-step send flow with a two-tap confirmation for amounts over $100 +- Bitcoin zaps -- send on-chain Bitcoin directly to anyone on Nostr as a native alternative to Lightning, with an automatic QR-code fallback when your signer does not support Bitcoin +- Detail pages for Bitcoin transactions and addresses, with the block explorer URL configurable per deployment +- Evolve ceremony -- Blobbis now transform from baby to adult through an immersive full-screen animation with energy particles, a flash-to-reveal, and a typewriter reveal text +- Birdex life lists -- a compact species tile strip in feeds and a full responsive grid on the post-detail page, so visitors can browse an author's whole collection +- Bird-song recordings play inline on Wikipedia species pages, with an emerald play button next to the title and a credit link in the footer + +### Changed +- Display names now use a consistent `name` then `display_name` fallback everywhere, so the same user renders the same way across the whole UI +- Hatching ceremony reveal background is now tinted by the baby Blobbi's color instead of a hardcoded blue, with a vignette overlay so the blobbi pops against same-hue backgrounds +- Bird Detection cards prefer the authoritative scientific name tag published by Birdstar, so cards stay labeled even when the post's alt text is generic + +### Fixed +- "Discover people to follow" now lands on a populated Global tab instead of another empty Follows view +- Blobbi daily bounty progress is no longer wiped by profile writes, and persists reliably across page refreshes and app restarts +- Blobbi profile content (name, avatar, custom fields) is preserved across every profile update instead of being silently dropped by some write paths +- Blobbi hatch and evolve mission progress no longer resets from concurrent writes racing against each other + ## [2.11.2] - 2026-04-28 ### Fixed diff --git a/NIP.md b/NIP.md index b4bd822f..c848c4fb 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) | @@ -428,7 +429,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 @@ -437,6 +438,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/android/app/build.gradle b/android/app/build.gradle index e3243cb9..a6027ad7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "2.11.2" + versionName "2.12.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index d4486ea2..d48291e4 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -327,7 +327,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.11.2; + MARKETING_VERSION = 2.12.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.11.2; + MARKETING_VERSION = 2.12.1; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package-lock.json b/package-lock.json index ff797902..1892cdc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.11.2", + "version": "2.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.11.2", + "version": "2.12.1", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/app": "^8.0.0", diff --git a/package.json b/package.json index a2673a0a..50e93793 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ditto", "private": true, - "version": "2.11.2", + "version": "2.12.1", "type": "module", "scripts": { "dev": "npm i --silent && vite", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index af730f17..242f15fa 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [2.12.1] - 2026-05-01 + +### Changed +- The right widget sidebar now appears on iPad-landscape (1024px) viewports, and both sidebars scale fluidly with the window instead of snapping at fixed widths +- Hashtags with internal hyphens like `#bitcoin-conference` and `#70-706` now render as a single tag instead of being cut off at the dash + +### Fixed +- Comments on country, book, and Bitcoin transaction/address pages now load correctly instead of showing an empty thread + +## [2.12.0] - 2026-04-30 + +### Added +- Bitcoin wallet -- a new Wallet tab in the sidebar shows your balance in USD with BTC underneath, a transaction history that collapses when empty, and a 3-step send flow with a two-tap confirmation for amounts over $100 +- Bitcoin zaps -- send on-chain Bitcoin directly to anyone on Nostr as a native alternative to Lightning, with an automatic QR-code fallback when your signer does not support Bitcoin +- Detail pages for Bitcoin transactions and addresses, with the block explorer URL configurable per deployment +- Evolve ceremony -- Blobbis now transform from baby to adult through an immersive full-screen animation with energy particles, a flash-to-reveal, and a typewriter reveal text +- Birdex life lists -- a compact species tile strip in feeds and a full responsive grid on the post-detail page, so visitors can browse an author's whole collection +- Bird-song recordings play inline on Wikipedia species pages, with an emerald play button next to the title and a credit link in the footer + +### Changed +- Display names now use a consistent `name` then `display_name` fallback everywhere, so the same user renders the same way across the whole UI +- Hatching ceremony reveal background is now tinted by the baby Blobbi's color instead of a hardcoded blue, with a vignette overlay so the blobbi pops against same-hue backgrounds +- Bird Detection cards prefer the authoritative scientific name tag published by Birdstar, so cards stay labeled even when the post's alt text is generic + +### Fixed +- "Discover people to follow" now lands on a populated Global tab instead of another empty Follows view +- Blobbi daily bounty progress is no longer wiped by profile writes, and persists reliably across page refreshes and app restarts +- Blobbi profile content (name, avatar, custom fields) is preserved across every profile update instead of being silently dropped by some write paths +- Blobbi hatch and evolve mission progress no longer resets from concurrent writes racing against each other + ## [2.11.2] - 2026-04-28 ### Fixed diff --git a/src/App.tsx b/src/App.tsx index f5c80a90..4f0735b4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,7 @@ const hardcodedConfig: AppConfig = { theme: "system", autoShareTheme: true, useAppRelays: true, + useUserRelays: false, relayMetadata: { relays: [], updatedAt: 0, @@ -114,6 +115,7 @@ const hardcodedConfig: AppConfig = { feedIncludeBlobbi: true, showBirdstar: true, feedIncludeBirdDetections: true, + feedIncludeBirdex: true, feedIncludeConstellations: true, followsFeedShowReplies: true, }, diff --git a/src/blobbi/companion/components/BlobbiCompanionLayer.tsx b/src/blobbi/companion/components/BlobbiCompanionLayer.tsx index 07b7fe82..b9b222e4 100644 --- a/src/blobbi/companion/components/BlobbiCompanionLayer.tsx +++ b/src/blobbi/companion/components/BlobbiCompanionLayer.tsx @@ -14,7 +14,7 @@ * This file should be placed at the app root level (renders a fixed overlay). */ -import { useCallback, useState, useMemo, useRef } from 'react'; +import { useCallback, useState, useMemo, useRef, useEffect } from 'react'; import { useBlobbiCompanion } from '../hooks/useBlobbiCompanion'; import { useCompanionItemReaction } from '../hooks/useCompanionItemReaction'; @@ -23,10 +23,12 @@ import { useOverstimulationReaction } from '../hooks/useOverstimulationReaction' import { useShakeReaction } from '../hooks/useShakeReaction'; import { createShakeTracker, recordSample, computeShakeResult, resetTracker } from '../core/shakeDetection'; import { BlobbiCompanion } from './BlobbiCompanion'; +import { VomitSplat } from './VomitSplat'; import { OverstimulationBlockOverlay } from './OverstimulationBlockOverlay'; import { DebugGroundOverlay } from './DebugGroundOverlay'; import { DEFAULT_COMPANION_CONFIG } from '../core/companionConfig'; import { calculateGroundY } from '../utils/movement'; +import { getBlobbiMouthAnchor } from '../utils/mouthAnchor'; import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction'; import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe'; import type { ActionType } from '@/blobbi/ui/lib/status-reactions'; @@ -45,6 +47,14 @@ import type { Position } from '../types/companion.types'; /** Set to true to show debug ground-contact lines. */ const DEBUG_GROUND_CONTACT = false; +interface SplatData { + id: number; + spawnX: number; + spawnY: number; + landX: number; + landY: number; +} + export function BlobbiCompanionLayer() { const { companion, @@ -157,6 +167,7 @@ export function BlobbiCompanionLayer() { const { recipe: shakeRecipe, recipeLabel: shakeLabel, + vomitEvent, onDragUpdate: shakeOnDragUpdate, onDragEnd: shakeOnDragEnd, onDragStart: shakeOnDragStart, @@ -189,6 +200,40 @@ export function BlobbiCompanionLayer() { endDrag(); }, [endDrag, shakeOnDragEnd]); + // ── Vomit splat management ───────────────────────────────────────────────── + + const [splats, setSplats] = useState([]); + const lastVomitId = useRef(0); + + useEffect(() => { + if (!vomitEvent || vomitEvent.id === lastVomitId.current || !companion) return; + lastVomitId.current = vomitEvent.id; + + // Compute spawn position (Blobbi's mouth area) + const mouth = getBlobbiMouthAnchor(companion.stage, companion.adultType); + const spawnX = renderedPosition.x + config.size * mouth.xRatio; + const spawnY = renderedPosition.y + config.size * mouth.yRatio; + + // Land about 20px below Blobbi's container bottom, clamped to viewport floor + const floorLimit = viewport.height - config.padding.bottom; + const landX = spawnX + (Math.random() * 30 - 15); + const landY = Math.min(renderedPosition.y + config.size + 20, floorLimit); + + const newSplat: SplatData = { + id: vomitEvent.id, + spawnX, + spawnY, + landX, + landY, + }; + + setSplats((prev) => [...prev, newSplat]); + }, [vomitEvent, renderedPosition, config.size, config.padding.bottom, viewport.height, companion]); + + const removeSplat = useCallback((id: number) => { + setSplats((prev) => prev.filter((s) => s.id !== id)); + }, []); + const handleItemUse = useCallback(async (item: CompanionItem): Promise<{ success: boolean; error?: string }> => { const action = CATEGORY_TO_ACTION[item.category]; @@ -341,6 +386,19 @@ export function BlobbiCompanionLayer() { /> )} + {/* Vomit splats — rendered below companion z-index */} + {splats.map((s) => ( + + ))} +
; recipe?: BlobbiVisualRecipe; + /** Pre-computed structural fingerprint (excludes angerRise.level). */ + recipeFingerprint: string; recipeLabel?: string; emotion: BlobbiEmotion; bodyEffects?: BodyEffectsSpec; @@ -79,6 +86,7 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({ blobbi, eyeOffsetRef, recipe, + recipeFingerprint: _recipeFingerprint, recipeLabel, emotion, bodyEffects, @@ -115,8 +123,17 @@ const MemoizedBlobbiVisual = memo(function MemoizedBlobbiVisual({ }, (prev, next) => { return ( prev.stage === next.stage && - prev.blobbi === next.blobbi && - prev.recipe === next.recipe && + // Compare blobbi by visual-identity primitives, NOT by reference. + // This prevents SVG rebuilds (and SMIL animation restarts) when the + // upstream companion object gets a new reference with identical content + // — e.g. during nausea recovery where only angerRise.level changes. + prev.blobbi.id === next.blobbi.id && + prev.blobbi.baseColor === next.blobbi.baseColor && + prev.blobbi.secondaryColor === next.blobbi.secondaryColor && + prev.blobbi.eyeColor === next.blobbi.eyeColor && + prev.blobbi.adult?.evolutionForm === next.blobbi.adult?.evolutionForm && + prev.blobbi.seed === next.blobbi.seed && + prev.recipeFingerprint === next.recipeFingerprint && prev.recipeLabel === next.recipeLabel && prev.emotion === next.emotion && prev.bodyEffects === next.bodyEffects @@ -142,6 +159,7 @@ export function BlobbiCompanionVisual({ className, debugMode = false, }: BlobbiCompanionVisualProps) { + const rootRef = useRef(null); const blobbi = useMemo(() => companionDataToBlobbi(companion), [companion]); // DEV ONLY: Get effective emotion from dev context (overrides production emotions) @@ -153,6 +171,14 @@ export function BlobbiCompanionVisual({ const effectiveEmotion = hasDevOverride ? devEmotion : (emotionProp ?? 'neutral'); const effectiveBodyEffects = hasDevOverride ? undefined : bodyEffectsProp; + // ── Fill level update (above memo boundary) ──────────────────────────────── + // Compute structural fingerprint (excludes angerRise.level) and run the + // imperative gradient-stop update from here. This allows MemoizedBlobbiVisual + // to block re-renders during level-only changes (e.g. nausea drain), keeping + // SMIL spiral-eye animations running uninterrupted. + const recipeFingerprint = useRecipeFingerprint(effectiveRecipe); + useFillLevelUpdate(rootRef, blobbi.id, effectiveRecipe); + // Float transform const blobbiTransform = useMemo(() => { const transforms: string[] = []; @@ -187,6 +213,7 @@ export function BlobbiCompanionVisual({ return (
@@ -263,6 +290,7 @@ export function BlobbiCompanionVisual({ blobbi={blobbi} eyeOffsetRef={eyeOffsetRef} recipe={effectiveRecipe} + recipeFingerprint={recipeFingerprint} recipeLabel={effectiveRecipeLabel} emotion={effectiveEmotion} bodyEffects={effectiveBodyEffects} diff --git a/src/blobbi/companion/components/VomitSplat.tsx b/src/blobbi/companion/components/VomitSplat.tsx new file mode 100644 index 00000000..48512830 --- /dev/null +++ b/src/blobbi/companion/components/VomitSplat.tsx @@ -0,0 +1,121 @@ +/** + * VomitSplat — Renders a vomit drop that falls from a spawn point to + * near Blobbi, then becomes a persistent puddle until the user clicks it. + * + * Lifecycle: + * 1. "falling" — CSS-animated drop from (spawnX, spawnY) to (landX, landY) + * 2. "landed" — Static puddle at (landX, landY), removed on click/tap + * + * The component is absolutely positioned inside the companion overlay layer + * (fixed inset-0). Coordinates are in viewport px. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; + +const FALL_DURATION_MS = 500; + +interface VomitSplatProps { + id: number; + spawnX: number; + spawnY: number; + landX: number; + landY: number; + onRemove: (id: number) => void; +} + +export function VomitSplat({ id, spawnX, spawnY, landX, landY, onRemove }: VomitSplatProps) { + const [landed, setLanded] = useState(false); + const fallTimer = useRef | null>(null); + + useEffect(() => { + fallTimer.current = setTimeout(() => { + fallTimer.current = null; + setLanded(true); + }, FALL_DURATION_MS); + + return () => { + if (fallTimer.current !== null) { + clearTimeout(fallTimer.current); + } + }; + }, []); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onRemove(id); + }, [id, onRemove]); + + const fallDeltaX = landX - spawnX; + const fallDeltaY = landY - spawnY; + + if (!landed) { + // Falling drop — animated from spawn to land position + // zIndex 10002 renders above companion (10000/10001) while falling + return ( +
+ +
+ ); + } + + // Landed puddle — interactive, click/tap to remove. + // translate(-50%, -100%) places the puddle's bottom edge at landY, + // so it sits like a splat on the ground rather than centering on the point. + return ( + + ); +} + +/** Small falling drop — green/yellow teardrop shape via SVG. */ +function VomitDrop() { + return ( + + ); +} + +/** Landed puddle — small green/yellow splat shape via SVG. */ +function VomitPuddle() { + return ( + + ); +} diff --git a/src/blobbi/companion/hooks/useShakeReaction.ts b/src/blobbi/companion/hooks/useShakeReaction.ts index c2f775bf..d9cb8cf1 100644 --- a/src/blobbi/companion/hooks/useShakeReaction.ts +++ b/src/blobbi/companion/hooks/useShakeReaction.ts @@ -1,8 +1,24 @@ /** * useShakeReaction — Blobbi gets dizzy (and optionally nauseous) when shaken. * - * Phases: idle → shaking → dizzy → recovering → idle. + * Produces a live visual reaction while the user is actively shaking, + * and sustains the dizzy state after release for a duration proportional + * to the total shake intensity. + * + * Phases: + * - idle: No shake reaction active + * - shaking: User is actively shaking (dizzy face + live nausea fill) + * - dizzy: Post-release hold (spiral eyes, sustained nausea level) + * - vomiting: Brief vomit expression (triggers vomitEvent for visual) + * - recovering: Nausea draining (rAF loop) + * * Nausea (green body fill) only triggers when hunger >= 90. + * Vomiting escalation requires peakIntensity >= 0.7 (independent of hunger). + * + * 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 drop below + * its current level, and the dizzy hold timer extends. */ import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; @@ -22,13 +38,22 @@ export interface ShakeReactionProfile { } const DIZZY_RECIPE = resolveVisualRecipe('dizzy'); + export const DIZZY_NAUSEA_PROFILE: ShakeReactionProfile = { dizzy: { recipe: DIZZY_RECIPE, label: 'dizzy' }, nauseated: { recipe: { ...DIZZY_RECIPE, mouth: { roundMouth: { rx: 5, ry: 6, filled: true } }, - eyebrows: { config: { angle: -15, offsetY: -12, strokeWidth: 1.5, color: '#6b7280', curve: 0.15 } }, + eyebrows: { + config: { + angle: -15, + offsetY: -12, + strokeWidth: 1.5, + color: '#6b7280', + curve: 0.15, + }, + }, }, label: 'nauseated', }, @@ -44,25 +69,38 @@ const DIZZY_MIN_S = 3; const DIZZY_MAX_S = 8; const DRAIN_RATE = 0.25; const VIS_THRESH = 0.02; +const VOMIT_INTENSITY_THRESH = 0.7; +const VOMIT_DURATION_MS = 1500; // ─── Types ──────────────────────────────────────────────────────────────────── -export type ShakeReactionPhase = 'idle' | 'shaking' | 'dizzy' | 'recovering'; +export type ShakeReactionPhase = 'idle' | 'shaking' | 'dizzy' | 'vomiting' | 'recovering'; + +/** Emitted once each time Blobbi vomits. Consumers should react to id changes. */ +export interface VomitEvent { + id: number; + intensity: number; +} export interface UseShakeReactionResult { phase: ShakeReactionPhase; nauseaLevel: number; recipe: BlobbiVisualRecipe | null; recipeLabel: string | null; + /** Non-null when a vomit event fires. New object ref on each trigger. */ + vomitEvent: VomitEvent | null; onDragUpdate: (result: ShakeResult) => void; onDragEnd: (result: ShakeResult) => void; + /** Call this when drag starts. Does not reset active reactions. */ onDragStart: () => void; } // ─── Hook ───────────────────────────────────────────────────────────────────── export function useShakeReaction({ - isActive, hunger, profile = DIZZY_NAUSEA_PROFILE, + isActive, + hunger, + profile = DIZZY_NAUSEA_PROFILE, }: { isActive: boolean; hunger: number; @@ -70,6 +108,14 @@ export function useShakeReaction({ }): UseShakeReactionResult { const [visLevel, setVisLevel] = useState(0); const [phase, setPhase] = useState('idle'); + const [vomitEvent, setVomitEvent] = useState(null); + + /** + * Once nausea activates in a cycle, keep using the nauseated recipe + * until the full cycle ends. This avoids a structural SVG rebuild + * that could kill SMIL spiral eye animations mid-reaction. + */ + const [cycleHadNausea, setCycleHadNausea] = useState(false); const lvl = useRef(0); const ph = useRef('idle'); @@ -77,112 +123,338 @@ export function useShakeReaction({ const raf = useRef(null); const prevT = useRef(0); const dizzyTimer = useRef | null>(null); + const vomitTimer = useRef | null>(null); const hungerRef = useRef(hunger); - hungerRef.current = hunger; const toasted = useRef(false); const prof = useRef(profile); + const cycleHadNauseaRef = useRef(false); + const peakIntensity = useRef(0); + const vomitIdCounter = useRef(0); + /** Prevents multiple vomit emissions in the same shake cycle. */ + const vomitedThisCycle = useRef(false); + + hungerRef.current = hunger; prof.current = profile; - const stop = useCallback(() => { if (raf.current !== null) { cancelAnimationFrame(raf.current); raf.current = null; } }, []); - const clearDizzy = useCallback(() => { if (dizzyTimer.current !== null) { clearTimeout(dizzyTimer.current); dizzyTimer.current = null; } }, []); + const stop = useCallback(() => { + if (raf.current !== null) { + cancelAnimationFrame(raf.current); + raf.current = null; + } + }, []); - const push = useCallback((level: number, p: ShakeReactionPhase) => { - const changed = p !== ph.current; - if (changed || Math.abs(level - lastVis.current) >= VIS_THRESH || (level === 0 && lastVis.current !== 0)) { + const clearDizzy = useCallback(() => { + if (dizzyTimer.current !== null) { + clearTimeout(dizzyTimer.current); + dizzyTimer.current = null; + } + }, []); + + const clearVomit = useCallback(() => { + if (vomitTimer.current !== null) { + clearTimeout(vomitTimer.current); + vomitTimer.current = null; + } + }, []); + + const push = useCallback((level: number, nextPhase: ShakeReactionPhase) => { + const changed = nextPhase !== ph.current; + + if ( + changed || + Math.abs(level - lastVis.current) >= VIS_THRESH || + (level === 0 && lastVis.current !== 0) + ) { lastVis.current = level; setVisLevel(level); } - if (changed) { ph.current = p; setPhase(p); } + + if (changed) { + ph.current = nextPhase; + setPhase(nextPhase); + } }, []); - // rAF drain loop — runs during dizzy + recovering - const tick = useCallback((now: number) => { - const dt = prevT.current > 0 ? (now - prevT.current) / 1000 : 0; - prevT.current = now; - const next = Math.max(0, lvl.current - DRAIN_RATE * dt); - lvl.current = next; - if (next <= 0) { - if (ph.current === 'recovering') { push(0, 'idle'); toasted.current = false; } - else push(0, ph.current); // dizzy: stay, timer handles transition - stop(); return; - } - push(next, ph.current); - raf.current = requestAnimationFrame(tick); - }, [push, stop]); + const finishCycle = useCallback(() => { + lvl.current = 0; + lastVis.current = 0; + toasted.current = false; + cycleHadNauseaRef.current = false; + peakIntensity.current = 0; + vomitedThisCycle.current = false; + setCycleHadNausea(false); + setVomitEvent(null); + push(0, 'idle'); + }, [push]); + + const tick = useCallback( + (now: number) => { + const dt = prevT.current > 0 ? (now - prevT.current) / 1000 : 0; + prevT.current = now; + + const next = Math.max(0, lvl.current - DRAIN_RATE * dt); + lvl.current = next; + + if (next <= 0) { + if (ph.current === 'recovering') { + finishCycle(); + } else { + // During dizzy hold, keep the phase. Timer handles dizzy → idle. + push(0, ph.current); + } + + stop(); + return; + } + + push(next, ph.current); + raf.current = requestAnimationFrame(tick); + }, + [finishCycle, push, stop], + ); const startDrain = useCallback(() => { if (raf.current !== null) return; + prevT.current = performance.now(); raf.current = requestAnimationFrame(tick); }, [tick]); + const startRef = useRef(startDrain); startRef.current = startDrain; const resetAll = useCallback(() => { - stop(); clearDizzy(); lvl.current = 0; toasted.current = false; + stop(); + clearDizzy(); + clearVomit(); + + lvl.current = 0; + lastVis.current = 0; + toasted.current = false; + cycleHadNauseaRef.current = false; + peakIntensity.current = 0; + vomitedThisCycle.current = false; + + setCycleHadNausea(false); + setVomitEvent(null); push(0, 'idle'); - }, [stop, clearDizzy, push]); + }, [stop, clearDizzy, clearVomit, push]); const onDragStart = useCallback(() => { - if (ph.current !== 'idle') resetAll(); - toasted.current = false; - }, [resetAll]); - - const onDragUpdate = useCallback((result: ShakeResult) => { - if (!result.triggered) return; - const sick = hungerRef.current >= HUNGER_THRESH; - const nausea = sick ? Math.min(1, result.intensity) : 0; - lvl.current = nausea; - if (ph.current === 'idle' || ph.current === 'shaking') { - push(nausea, 'shaking'); - if (sick && !toasted.current) { - toasted.current = true; - toast({ title: 'Careful\u2026', description: 'Blobbi is feeling sick!' }); - } + // Starting a new drag while dizzy/recovering should not reset the cycle. + if (ph.current === 'idle') { + toasted.current = false; } - }, [push]); - const onDragEnd = useCallback((result: ShakeResult) => { - const wasShaking = ph.current === 'shaking'; - if (!result.triggered && !wasShaking) return; - const intensity = result.triggered ? result.intensity : 0; - const dur = DIZZY_MIN_S + intensity * (DIZZY_MAX_S - DIZZY_MIN_S); - const sick = hungerRef.current >= HUNGER_THRESH; - const nausea = sick ? Math.min(1, intensity) : 0; - lvl.current = nausea; - push(nausea, 'dizzy'); - if (nausea > 0) startRef.current(); - clearDizzy(); - dizzyTimer.current = setTimeout(() => { - dizzyTimer.current = null; - if (lvl.current > 0) push(lvl.current, 'recovering'); - else { push(0, 'idle'); toasted.current = false; } - }, dur * 1000); - }, [push, clearDizzy]); + // Allow one vomit per drag: reset the per-drag guard when a new drag begins + // during an active cycle so the next release can qualify independently. + if (ph.current === 'dizzy' || ph.current === 'recovering' || ph.current === 'vomiting') { + vomitedThisCycle.current = false; + peakIntensity.current = 0; + } + }, []); - useEffect(() => { if (!isActive) resetAll(); }, [isActive, resetAll]); - useEffect(() => () => { stop(); clearDizzy(); }, [stop, clearDizzy]); + const onDragUpdate = useCallback( + (result: ShakeResult) => { + if (!result.triggered) return; + + const sick = hungerRef.current >= HUNGER_THRESH; + const nausea = sick ? Math.min(1, result.intensity) : 0; + + // Track peak intensity for vomit escalation check + peakIntensity.current = Math.max(peakIntensity.current, result.intensity); + + if (sick && !cycleHadNauseaRef.current) { + cycleHadNauseaRef.current = true; + setCycleHadNausea(true); + } + + // Re-shaking should only raise the fill, never lower it. + lvl.current = Math.max(lvl.current, nausea); + + const currentPhase = ph.current; + + if ( + currentPhase === 'idle' || + currentPhase === 'shaking' || + currentPhase === 'dizzy' || + currentPhase === 'recovering' + ) { + if (currentPhase === 'dizzy' || currentPhase === 'recovering') { + clearDizzy(); + stop(); + } + + push(lvl.current, 'shaking'); + + if (sick && !toasted.current) { + toasted.current = true; + toast({ + title: 'Careful\u2026', + description: 'Blobbi is feeling sick!', + }); + } + } + }, + [clearDizzy, push, stop], + ); + + const onDragEnd = useCallback( + (result: ShakeResult) => { + const wasShaking = ph.current === 'shaking'; + + if (!result.triggered && !wasShaking) return; + + const intensity = result.triggered ? result.intensity : 0; + peakIntensity.current = Math.max(peakIntensity.current, intensity); + + const sick = hungerRef.current >= HUNGER_THRESH; + const nausea = sick ? Math.min(1, intensity) : 0; + + if (sick && nausea > 0 && !cycleHadNauseaRef.current) { + cycleHadNauseaRef.current = true; + setCycleHadNausea(true); + } + + // Keep the strongest accumulated nausea level. + lvl.current = Math.max(lvl.current, nausea); + + // ── Immediate vomit on release if conditions met ─────────────────── + if ( + peakIntensity.current >= VOMIT_INTENSITY_THRESH && + !vomitedThisCycle.current + ) { + vomitedThisCycle.current = true; + + push(lvl.current, 'vomiting'); + stop(); // pause drain during vomit + + vomitIdCounter.current += 1; + setVomitEvent({ id: vomitIdCounter.current, intensity: peakIntensity.current }); + + clearVomit(); + vomitTimer.current = setTimeout(() => { + vomitTimer.current = null; + + // After vomit expression, transition to dizzy hold then recover + const dur = DIZZY_MIN_S + intensity * (DIZZY_MAX_S - DIZZY_MIN_S); + + push(lvl.current, 'dizzy'); + + if (lvl.current > 0) { + startRef.current(); + } + + clearDizzy(); + dizzyTimer.current = setTimeout(() => { + dizzyTimer.current = null; + + if (lvl.current > 0) { + push(lvl.current, 'recovering'); + startRef.current(); + } else { + finishCycle(); + } + }, dur * 1000); + }, VOMIT_DURATION_MS); + + return; + } + + // ── Standard path (no vomit) ────────────────────────────────────── + push(lvl.current, 'dizzy'); + + if (lvl.current > 0) { + stop(); + startRef.current(); + } + + clearDizzy(); + + const dur = DIZZY_MIN_S + intensity * (DIZZY_MAX_S - DIZZY_MIN_S); + + dizzyTimer.current = setTimeout(() => { + dizzyTimer.current = null; + + if (lvl.current > 0) { + push(lvl.current, 'recovering'); + startRef.current(); + } else { + finishCycle(); + } + }, dur * 1000); + }, + [clearDizzy, clearVomit, finishCycle, push, stop], + ); + + useEffect(() => { + if (!isActive) resetAll(); + }, [isActive, resetAll]); + + useEffect(() => { + return () => { + stop(); + clearDizzy(); + clearVomit(); + }; + }, [stop, clearDizzy, clearVomit]); - // Resolve phase + level → recipe return useMemo((): UseShakeReactionResult => { - const base = { onDragUpdate, onDragEnd, onDragStart }; - if (phase === 'idle') - return { ...base, phase: 'idle', nauseaLevel: 0, recipe: null, recipeLabel: null }; + const base = { onDragUpdate, onDragEnd, onDragStart, vomitEvent }; + + if (phase === 'idle') { + return { + ...base, + phase: 'idle', + nauseaLevel: 0, + recipe: null, + recipeLabel: null, + }; + } + const p = prof.current; - if (visLevel > 0) { + + if (visLevel > 0 && cycleHadNausea) { const recipe: BlobbiVisualRecipe = { ...p.nauseated.recipe, bodyEffects: { ...p.nauseated.recipe.bodyEffects, angerRise: { - color: p.nauseaFillColor, duration: 0, level: visLevel, - bottomOpacity: p.nauseaBottomOpacity, edgeOpacity: p.nauseaEdgeOpacity, + color: p.nauseaFillColor, + duration: 0, + level: visLevel, + bottomOpacity: p.nauseaBottomOpacity, + edgeOpacity: p.nauseaEdgeOpacity, }, }, }; - return { ...base, phase, nauseaLevel: visLevel, recipe, recipeLabel: p.nauseated.label }; + + return { + ...base, + phase, + nauseaLevel: visLevel, + recipe, + recipeLabel: p.nauseated.label, + }; } - return { ...base, phase, nauseaLevel: 0, recipe: p.dizzy.recipe, recipeLabel: p.dizzy.label }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [phase, visLevel, profile, onDragUpdate, onDragEnd, onDragStart]); -} + + if (cycleHadNausea) { + return { + ...base, + phase, + nauseaLevel: 0, + recipe: p.nauseated.recipe, + recipeLabel: p.nauseated.label, + }; + } + + return { + ...base, + phase, + nauseaLevel: 0, + recipe: p.dizzy.recipe, + recipeLabel: p.dizzy.label, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, visLevel, cycleHadNausea, vomitEvent, profile, onDragUpdate, onDragEnd, onDragStart]); +} \ No newline at end of file diff --git a/src/blobbi/companion/utils/mouthAnchor.test.ts b/src/blobbi/companion/utils/mouthAnchor.test.ts new file mode 100644 index 00000000..2d2ce44a --- /dev/null +++ b/src/blobbi/companion/utils/mouthAnchor.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { getBlobbiMouthAnchor } from './mouthAnchor'; + +describe('getBlobbiMouthAnchor', () => { + it('returns baby mouth ratios with visual offset', () => { + const result = getBlobbiMouthAnchor('baby'); + expect(result.xRatio).toBe(0.5); + expect(result.yRatio).toBeCloseTo(68 / 100 + 0.12, 5); + }); + + it('returns correct ratio for a high-mouth adult (leafy)', () => { + const result = getBlobbiMouthAnchor('adult', 'leafy'); + expect(result.xRatio).toBe(0.5); + expect(result.yRatio).toBeCloseTo(100 / 200 + 0.12, 5); + }); + + it('returns correct ratio for a low-mouth adult (mushie)', () => { + const result = getBlobbiMouthAnchor('adult', 'mushie'); + expect(result.xRatio).toBe(0.5); + expect(result.yRatio).toBeCloseTo(153 / 200 + 0.12, 5); + }); + + it('returns fallback for egg stage', () => { + const result = getBlobbiMouthAnchor('egg'); + expect(result).toEqual({ xRatio: 0.5, yRatio: 0.75 }); + }); + + it('returns fallback for unknown adult type', () => { + const result = getBlobbiMouthAnchor('adult', 'unknownform'); + expect(result).toEqual({ xRatio: 0.5, yRatio: 0.75 }); + }); + + it('returns fallback for adult with no adultType', () => { + const result = getBlobbiMouthAnchor('adult'); + expect(result).toEqual({ xRatio: 0.5, yRatio: 0.75 }); + }); +}); diff --git a/src/blobbi/companion/utils/mouthAnchor.ts b/src/blobbi/companion/utils/mouthAnchor.ts new file mode 100644 index 00000000..4087292a --- /dev/null +++ b/src/blobbi/companion/utils/mouthAnchor.ts @@ -0,0 +1,66 @@ +/** + * mouthAnchor — Static lookup for Blobbi mouth position ratios. + * + * Returns normalized x/y ratios (0–1) relative to the companion container, + * already accounting for the internal +0.12 translateY shift applied by + * BlobbiCompanionVisual. + * + * Used to position the vomit drop spawn point at the actual mouth. + */ + +import { ADULT_FORMS, type AdultForm } from '@/blobbi/adult-blobbi/types/adult.types'; + +// ─── Internal visual wrapper shift (BlobbiCompanionVisual translateY) ──────── +const VISUAL_Y_OFFSET = 0.12; + +// ─── Baby mouth: controlY = 68 in 100×100 viewBox ─────────────────────────── +const BABY_MOUTH_Y_RATIO = 68 / 100 + VISUAL_Y_OFFSET; + +// ─── Adult mouths: controlY values in 200×200 viewBox ──────────────────────── +const ADULT_MOUTH_Y_RATIO: Record = { + bloomi: 128 / 200 + VISUAL_Y_OFFSET, + breezy: 120 / 200 + VISUAL_Y_OFFSET, + cacti: 126 / 200 + VISUAL_Y_OFFSET, + catti: 128 / 200 + VISUAL_Y_OFFSET, + cloudi: 122 / 200 + VISUAL_Y_OFFSET, + crysti: 123 / 200 + VISUAL_Y_OFFSET, + droppi: 123 / 200 + VISUAL_Y_OFFSET, + flammi: 125 / 200 + VISUAL_Y_OFFSET, + froggi: 145 / 200 + VISUAL_Y_OFFSET, + leafy: 100 / 200 + VISUAL_Y_OFFSET, + mushie: 153 / 200 + VISUAL_Y_OFFSET, + owli: 120 / 200 + VISUAL_Y_OFFSET, + pandi: 118 / 200 + VISUAL_Y_OFFSET, + rocky: 123 / 200 + VISUAL_Y_OFFSET, + rosey: 106 / 200 + VISUAL_Y_OFFSET, + starri: 125 / 200 + VISUAL_Y_OFFSET, +}; + +const ADULT_FORMS_SET: ReadonlySet = new Set(ADULT_FORMS); + +export interface MouthAnchorRatios { + xRatio: number; + yRatio: number; +} + +/** + * Get the mouth anchor ratios for a given Blobbi stage and optional adult type. + * + * The returned ratios are multiplied by `config.size` and added to + * `renderedPosition` to get viewport-pixel coordinates of the mouth. + */ +export function getBlobbiMouthAnchor( + stage: 'egg' | 'baby' | 'adult', + adultType?: string, +): MouthAnchorRatios { + if (stage === 'baby') { + return { xRatio: 0.5, yRatio: BABY_MOUTH_Y_RATIO }; + } + + if (stage === 'adult' && adultType && ADULT_FORMS_SET.has(adultType)) { + return { xRatio: 0.5, yRatio: ADULT_MOUTH_Y_RATIO[adultType as AdultForm] }; + } + + // Fallback for egg or unknown adult type + return { xRatio: 0.5, yRatio: 0.75 }; +} 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/blobbi/rooms/components/BlobbiRoomHero.tsx b/src/blobbi/rooms/components/BlobbiRoomHero.tsx index 6945a2aa..5a453a28 100644 --- a/src/blobbi/rooms/components/BlobbiRoomHero.tsx +++ b/src/blobbi/rooms/components/BlobbiRoomHero.tsx @@ -153,7 +153,8 @@ export function BlobbiRoomHero({
(null); const recipeFingerprint = useRecipeFingerprint(recipeProp); - useFillLevelUpdate(containerRef, blobbi.id, recipeProp); const customizedSvg = useMemo(() => { debugBlobbi('svg-rebuild', 'adult customizedSvg rebuild'); @@ -96,17 +94,17 @@ export function BlobbiAdultSvgRenderer({ } return animatedSvg; - // recipeFingerprint replaces recipeProp in the dep list so that - // level-only changes do NOT trigger a full SVG rebuild. The closure - // captures the current recipeProp for the rare structural rebuilds. + // Deps use stable primitives from blobbi (not the object reference) and + // recipeFingerprint (not recipeProp) so that level-only changes and + // upstream reference churn do NOT trigger full SVG rebuilds. The closure + // captures the current blobbi/recipeProp for the rare structural rebuilds. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [blobbi, recipeFingerprint, recipeLabel, emotion, bodyEffects]); + }, [blobbi.id, blobbi.baseColor, blobbi.secondaryColor, blobbi.eyeColor, blobbi.adult?.evolutionForm, blobbi.seed, recipeFingerprint, recipeLabel, emotion, bodyEffects]); const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]); return (
diff --git a/src/blobbi/ui/BlobbiBabySvgRenderer.tsx b/src/blobbi/ui/BlobbiBabySvgRenderer.tsx index 1eb22b38..ba5f96c1 100644 --- a/src/blobbi/ui/BlobbiBabySvgRenderer.tsx +++ b/src/blobbi/ui/BlobbiBabySvgRenderer.tsx @@ -17,7 +17,7 @@ * - Companion runtime (drag, float, position) */ -import { useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi'; import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg'; @@ -27,7 +27,7 @@ import { resolveVisualRecipe, applyVisualRecipe, type BlobbiVisualRecipe } from import type { BlobbiEmotion } from './lib/emotion-types'; import { applyBodyEffects, type BodyEffectsSpec } from './lib/bodyEffects'; import { debugBlobbi } from './lib/debug'; -import { useRecipeFingerprint, useFillLevelUpdate } from './hooks/useFillLevelUpdate'; +import { useRecipeFingerprint } from './hooks/useFillLevelUpdate'; import type { Blobbi } from '@/blobbi/core/types/blobbi'; export interface BlobbiBabySvgRendererProps { @@ -67,9 +67,7 @@ export function BlobbiBabySvgRenderer({ bodyEffects, className, }: BlobbiBabySvgRendererProps) { - const containerRef = useRef(null); const recipeFingerprint = useRecipeFingerprint(recipeProp); - useFillLevelUpdate(containerRef, blobbi.id, recipeProp); const customizedSvg = useMemo(() => { debugBlobbi('svg-rebuild', 'baby customizedSvg rebuild'); @@ -92,14 +90,17 @@ export function BlobbiBabySvgRenderer({ } return animatedSvg; + // Deps use stable primitives from blobbi (not the object reference) and + // recipeFingerprint (not recipeProp) so that level-only changes and + // upstream reference churn do NOT trigger full SVG rebuilds. The closure + // captures the current blobbi/recipeProp for the rare structural rebuilds. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [blobbi, recipeFingerprint, recipeLabel, emotion, bodyEffects]); + }, [blobbi.id, blobbi.baseColor, blobbi.secondaryColor, blobbi.eyeColor, blobbi.seed, recipeFingerprint, recipeLabel, emotion, bodyEffects]); const safeSvg = useMemo(() => sanitizeBlobbiSvg(customizedSvg), [customizedSvg]); return (
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/ArticleContent.tsx b/src/components/ArticleContent.tsx index 4b753a77..7c6d634f 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -1,7 +1,12 @@ -import Markdown from 'react-markdown'; +import { Children, createElement, type ReactNode } from 'react'; +import Markdown, { type Components } from 'react-markdown'; import rehypeSanitize from 'rehype-sanitize'; import type { NostrEvent } from '@nostrify/nostrify'; +import { NoteContent } from '@/components/NoteContent'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; + /** Gets a tag value by name. */ function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; @@ -14,6 +19,115 @@ interface ArticleContentProps { className?: string; } +/** Options controlling how text leaves are enriched inside a particular markdown tag. */ +interface EnrichOptions { + /** When true, `nostr:nevent/note/naddr` URIs render as inline links (not block-level quote cards), + * and media embeds (images/video/audio) are suppressed. Used for headings and other tight contexts. */ + inlineOnly?: boolean; +} + +/** + * Recursively walk markdown children, replacing each string leaf with a + * `` instance so Nostr URIs, URLs, hashtags, and + * custom emoji render with identical behavior to regular note content + * (mentions, quoted-note cards, link-preview cards, images, custom emoji). + * + * The synthetic event clones the article's own tags so NIP-30 emoji, + * imeta metadata, and q-tag relay hints resolve correctly for each run. + */ +function enrichChildren( + children: ReactNode, + event: NostrEvent, + opts: EnrichOptions = {}, +): ReactNode { + return Children.map(children, (child, i) => { + if (typeof child === 'string') { + const synthetic: NostrEvent = { ...event, content: child }; + return ( + + ); + } + return child; + }); +} + +/** Build react-markdown component overrides for this article's event. */ +function buildComponents(event: NostrEvent): Components { + // Wrap a text-bearing block/inline element so its string leaves are enriched. + // Uses `createElement` to sidestep TS widening issues when spreading + // unknown rehype-passed props onto a generic intrinsic tag. + function wrap(Tag: keyof React.JSX.IntrinsicElements, opts: EnrichOptions = {}) { + return function Wrapped( + props: { children?: ReactNode } & Record, + ) { + const { children, node: _node, ...rest } = props; + return createElement(Tag, rest, enrichChildren(children, event, opts)); + }; + } + + return { + // Paragraphs render as `
` so block-level embeds (quoted notes, + // images, link-preview cards) inside them produce valid HTML. + // Reproduce prose-sm paragraph spacing with utility classes. + p: ({ children, node: _node, ...rest }: { children?: ReactNode } & Record) => + createElement( + 'div', + { + ...rest, + className: cn('my-[1em] first:mt-0 last:mb-0', rest.className as string | undefined), + }, + enrichChildren(children, event), + ), + li: wrap('li'), + // Headings: keep inline linkification (mentions, hashtags, URL links) + // but suppress block embeds so a heading can't contain a giant quote card. + h1: wrap('h1', { inlineOnly: true }), + h2: wrap('h2', { inlineOnly: true }), + h3: wrap('h3', { inlineOnly: true }), + h4: wrap('h4', { inlineOnly: true }), + h5: wrap('h5', { inlineOnly: true }), + h6: wrap('h6', { inlineOnly: true }), + strong: wrap('strong'), + em: wrap('em'), + blockquote: wrap('blockquote'), + td: wrap('td'), + th: wrap('th'), + a: ({ href, children, node: _node, ...rest }) => { + const safe = sanitizeUrl(href); + if (!safe) { + // Unsafe href — render label as plain text so we don't emit a dead/dangerous link. + return {children}; + } + return ( + e.stopPropagation()} + > + {children} + + ); + }, + img: ({ src, alt, node: _node, ...rest }) => { + const safe = typeof src === 'string' ? sanitizeUrl(src) : undefined; + if (!safe) return null; + return {alt; + }, + } as Components; +} + /** Renders kind 30023 long-form article content with Markdown. */ export function ArticleContent({ event, preview, className }: ArticleContentProps) { const title = getTag(event.tags, 'title'); @@ -46,6 +160,8 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp ); } + const components = buildComponents(event); + return (
{title && ( @@ -58,8 +174,8 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp className="w-full rounded-xl object-cover max-h-96 mb-6" /> )} -
- +
+ {event.content}
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 ( + + + + ); +} + +/** + * Four vertical bars bouncing with staggered CSS animations, + * rendered inside the button while playback is active. Color is + * `currentColor` so it inherits the button's text colour — white on + * the emerald background in light mode, emerald-950 (matching + * foreground) in dark mode. Respects `prefers-reduced-motion` via + * Tailwind's `motion-reduce:` variant so the bars freeze rather + * than bouncing for users who've asked for less motion. + */ +function EqualiserBars() { + const delays = ['0ms', '120ms', '60ms', '180ms']; + return ( + + {delays.map((delay, i) => ( + + ))} + + ); +} 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..e60919bd --- /dev/null +++ b/src/components/BirdexTile.tsx @@ -0,0 +1,118 @@ +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. 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 && scientificName !== commonName && ( +

+ {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/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index f8e88bde..0bf77c23 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -124,7 +124,7 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str const { data } = useAuthor(pubkey); const metadata: NostrMetadata | undefined = data?.metadata; const avatarShape = getAvatarShape(metadata); - const name = metadata?.display_name || metadata?.name || genUserName(pubkey); + const name = metadata?.name || metadata?.display_name || genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, metadata); const avatarCls = size === 'sm' ? 'size-8' : 'size-11'; const fallbackCls = size === 'sm' ? 'text-xs' : ''; diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 16982806..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 nip19.npubEncode(pubkey), [pubkey]); const parentLink = useMemo(() => { if (!eventId) return undefined; @@ -466,7 +468,7 @@ function AddrCommentContext({ root, className }: { root: CommentRoot; className? function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; - const displayName = metadata?.name ?? genUserName(pubkey); + const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey); const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); const listLink = useMemo( () => `/${nip19.naddrEncode({ kind: 3, pubkey, identifier: '' })}`, @@ -500,7 +502,7 @@ function FollowListCommentContext({ pubkey, className }: { pubkey: string; class function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; - const displayName = metadata?.name ?? genUserName(pubkey); + const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey); const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); return ( @@ -523,7 +525,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c const pubkey = root.addr?.pubkey ?? ''; const author = useAuthor(pubkey); const metadata = author.data?.metadata; - const displayName = metadata?.name ?? genUserName(pubkey); + const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey); const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); // Build naddr link for the profile badges event diff --git a/src/components/CommunityContent.tsx b/src/components/CommunityContent.tsx index 08d9dc8a..a3e9bde6 100644 --- a/src/components/CommunityContent.tsx +++ b/src/components/CommunityContent.tsx @@ -50,7 +50,7 @@ function ModeratorRow({ pubkey }: { pubkey: string }) { const { data } = useAuthor(pubkey); const metadata: NostrMetadata | undefined = data?.metadata; const avatarShape = getAvatarShape(metadata); - const name = metadata?.display_name || metadata?.name || genUserName(pubkey); + const name = metadata?.name || metadata?.display_name || genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, metadata); return ( @@ -89,7 +89,7 @@ export function CommunityContent({ event }: { event: NostrEvent }) { const ownerAuthor = useAuthor(event.pubkey); const ownerMetadata = ownerAuthor.data?.metadata; const ownerAvatarShape = getAvatarShape(ownerMetadata); - const ownerName = ownerMetadata?.display_name || ownerMetadata?.name || genUserName(event.pubkey); + const ownerName = ownerMetadata?.name || ownerMetadata?.display_name || genUserName(event.pubkey); const ownerProfileUrl = useProfileUrl(event.pubkey, ownerMetadata); // Extract website URL from description if present diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 20336798..cfab96dc 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -45,6 +45,7 @@ import { formatTime } from '@/lib/formatTime'; import { genUserName } from '@/lib/genUserName'; import { DITTO_RELAY } from '@/lib/appRelays'; import { resizeImage } from '@/lib/resizeImage'; +import { extractHashtags } from '@/lib/hashtag'; import { useIsMobile } from '@/hooks/useIsMobile'; const MAX_CHARS = 5000; @@ -490,8 +491,8 @@ export function ComposeBox({ const mockEvent = useMemo(() => { if (!user || !content) return null; - const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || []; - const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]); + const hashtags = extractHashtags(content); + const tags: string[][] = hashtags.map((t) => ['t', t]); // NIP-30: Add emoji tags for custom emojis referenced in content if (customEmojis.length > 0) { @@ -791,8 +792,8 @@ export function ComposeBox({ if (!content.trim() || !user || charCount > MAX_CHARS) return; try { - const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || []; - const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]); + const hashtags = extractHashtags(content); + const tags: string[][] = hashtags.map((t) => ['t', t]); // NIP-27 mention p tags — extract nostr:npub1... from content const mentionMatches = content.matchAll(/nostr:(npub1[023456789acdefghjklmnpqrstuvwxyz]+)/g); @@ -1136,7 +1137,7 @@ export function ComposeBox({ - {(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'} + {(metadata?.name || metadata?.display_name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'} diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index a0f6724e..81e73794 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -1148,7 +1148,7 @@ function MutedUserProfile({ 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); if (author.isLoading) { return ( diff --git a/src/components/EmbeddedCardShell.tsx b/src/components/EmbeddedCardShell.tsx index 0ef77a30..a03c8875 100644 --- a/src/components/EmbeddedCardShell.tsx +++ b/src/components/EmbeddedCardShell.tsx @@ -44,7 +44,7 @@ export function EmbeddedCardShell({ 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); return ( diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 455306fd..a7249077 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -216,7 +216,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv 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 profileUrl = useProfileUrl(event.pubkey, metadata); const badgeRefs = useMemo(() => parseProfileBadges(event), [event]); diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index ca357ec1..e371d8a4 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -126,7 +126,7 @@ function EmbeddedBadgeAwardCard({ event, className, disableHoverCards }: { event const issuer = useAuthor(event.pubkey); const issuerMeta = issuer.data?.metadata; - const issuerName = issuerMeta?.name || genUserName(event.pubkey); + const issuerName = issuerMeta?.name || issuerMeta?.display_name || genUserName(event.pubkey); const issuerProfileUrl = useProfileUrl(event.pubkey, issuerMeta); return ( @@ -203,7 +203,7 @@ function EmbeddedZapCard({ event, className, disableHoverCards }: { event: Nostr const sender = useAuthor(senderPubkey || undefined); const senderMeta = sender.data?.metadata; - const senderName = senderMeta?.name || (senderPubkey ? genUserName(senderPubkey) : 'Someone'); + const senderName = senderMeta?.name || senderMeta?.display_name || (senderPubkey ? genUserName(senderPubkey) : 'Someone'); const senderShape = getAvatarShape(senderMeta); const senderProfileUrl = useProfileUrl(senderPubkey, senderMeta); diff --git a/src/components/EmbeddedPeopleListCard.tsx b/src/components/EmbeddedPeopleListCard.tsx index 6cf734de..207dc859 100644 --- a/src/components/EmbeddedPeopleListCard.tsx +++ b/src/components/EmbeddedPeopleListCard.tsx @@ -108,7 +108,7 @@ export function EmbeddedPeopleListCard({ event, className, disableHoverCards }:
{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 ( n === 'p')?.[1]; const recipientAuthor = useAuthor(recipientPubkey ?? ''); const recipientName = recipientPubkey diff --git a/src/components/EncryptedMessageContent.tsx b/src/components/EncryptedMessageContent.tsx index eb916469..24591c85 100644 --- a/src/components/EncryptedMessageContent.tsx +++ b/src/components/EncryptedMessageContent.tsx @@ -130,7 +130,7 @@ export function EncryptedMessageCompact({ event, className }: EncryptedMessageCo 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 recipientPubkey = event.tags.find(([n]) => n === 'p')?.[1]; const recipientAuthor = useAuthor(recipientPubkey ?? ''); const recipientName = recipientPubkey diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 05894447..94fda81b 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -12,6 +12,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; import { BlueskyIcon } from '@/components/icons/BlueskyIcon'; import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader'; +import { BirdSongPlayer } from '@/components/BirdSongPlayer'; import { CardsIcon } from '@/components/icons/CardsIcon'; import { extractYouTubeId, extractWikipediaTitle, extractWikidataId, extractBlueskyPost, extractGathererCard, type GathererCard } from '@/lib/linkEmbed'; import { GathererCardHeader } from '@/components/GathererCardHeader'; @@ -32,6 +33,7 @@ import { genUserName } from '@/lib/genUserName'; import { getCountryInfo, getWikipediaTitle } from '@/lib/countries'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; import { useWikidataEntity } from '@/hooks/useWikidataEntity'; +import { useBirdSong } from '@/hooks/useBirdSong'; import { EXTRA_KINDS } from '@/lib/extraKinds'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; import { cn } from '@/lib/utils'; @@ -355,6 +357,12 @@ const WIKI_ARTICLE_MAX_HEIGHT = 160; // px — extract taller than this gets tru function WikipediaArticleHeader({ title, url }: { title: string; url: string }) { const { data: wiki, isLoading } = useWikipediaSummary(title); + // Resolve a reference recording from the article (if any). Shares a + // queryKey with BirdSongPlayer's internal lookup so this second + // subscription is cache-hit and free — we only use the result here + // to decide whether to render the attribution row below the + // article, not to trigger a second network request. + const { data: song } = useBirdSong(title); const contentRef = useRef(null); const [overflows, setOverflows] = useState(false); @@ -421,10 +429,25 @@ function WikipediaArticleHeader({ title, url }: { title: string; url: string }) Wikipedia
- {/* 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. */} +
); @@ -1133,7 +1176,7 @@ export function ProfilePreview({ 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); if (author.isLoading) { @@ -1229,6 +1272,7 @@ const WELL_KNOWN_KIND_LABELS: Record = { 35128: 'Nsite', 31124: 'Blobbi', 2473: 'Bird Detection', + 12473: 'Birdex', 30621: 'Constellation', }; @@ -1236,7 +1280,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey const { data: event, isLoading } = useAddrEvent(addr); 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 kindDef = useMemo( () => EXTRA_KINDS.find((d) => d.kind === addr.kind || d.subKinds?.some((s) => s.kind === addr.kind)), @@ -1256,6 +1300,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/FollowQRDialog.tsx b/src/components/FollowQRDialog.tsx index 3a34c0c7..f5f2052c 100644 --- a/src/components/FollowQRDialog.tsx +++ b/src/components/FollowQRDialog.tsx @@ -25,7 +25,7 @@ export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) { const [copied, setCopied] = useState(false); const metadata = author.data?.metadata; - const displayName = user ? metadata?.name || genUserName(user.pubkey) : ''; + const displayName = user ? metadata?.name || metadata?.display_name || genUserName(user.pubkey) : ''; const npub = user ? nip19.npubEncode(user.pubkey) : ''; const followUrl = npub ? `${shareOrigin}/follow/${npub}` : ''; diff --git a/src/components/InitialSyncGate.tsx b/src/components/InitialSyncGate.tsx index dfacb725..e7a56f68 100644 --- a/src/components/InitialSyncGate.tsx +++ b/src/components/InitialSyncGate.tsx @@ -1049,7 +1049,7 @@ function PackCard({
{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); return ( diff --git a/src/components/InteractionsModal.tsx b/src/components/InteractionsModal.tsx index c5e0d626..8f19c99b 100644 --- a/src/components/InteractionsModal.tsx +++ b/src/components/InteractionsModal.tsx @@ -237,7 +237,7 @@ function RepostRow({ entry }: { entry: RepostEntry }) { const author = useAuthor(entry.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(entry.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(entry.pubkey); const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]); return ( @@ -275,7 +275,7 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) { const author = useAuthor(entry.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(entry.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(entry.pubkey); const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]); const customName = isCustomEmoji(entry.emoji) ? entry.emoji.slice(1, -1) : undefined; @@ -324,7 +324,7 @@ function ZapRow({ zap }: { zap: ZapEntry }) { const author = useAuthor(zap.senderPubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(zap.senderPubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(zap.senderPubkey); const nevent = useMemo(() => nip19.neventEncode({ id: zap.eventId, author: zap.senderPubkey }), [zap.eventId, zap.senderPubkey]); return ( @@ -370,7 +370,7 @@ function QuoteRow({ quote }: { quote: QuoteEntry }) { const author = useAuthor(quote.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(quote.pubkey); + const displayName = metadata?.name || metadata?.display_name || genUserName(quote.pubkey); const nevent = useMemo(() => nip19.neventEncode({ id: quote.eventId, author: quote.pubkey }), [quote.eventId, quote.pubkey]); return ( diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index 427f3637..83e06a32 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -76,7 +76,7 @@ export function LeftSidebar() { } }, [location.pathname]); - const getDisplayName = (account: Account) => account.metadata.display_name || account.metadata.name || genUserName(account.pubkey); + const getDisplayName = (account: Account) => account.metadata.name || account.metadata.display_name || genUserName(account.pubkey); const handleLogout = async () => { setAccountPopoverOpen(false); @@ -85,7 +85,7 @@ export function LeftSidebar() { }; return ( -