Merge branch 'main' into feat/blobbi-1124-interactions

This commit is contained in:
filemon
2026-05-04 13:29:31 -03:00
111 changed files with 2223 additions and 319 deletions
+30
View File
@@ -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
+3 -1
View File
@@ -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)
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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 = "";
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.11.2",
"version": "2.12.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
+30
View File
@@ -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
+2
View File
@@ -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,
},
@@ -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<SplatData[]>([]);
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) => (
<VomitSplat
key={s.id}
id={s.id}
spawnX={s.spawnX}
spawnY={s.spawnY}
landX={s.landX}
landY={s.landY}
onRemove={removeSplat}
/>
))}
<div className="pointer-events-auto">
<BlobbiCompanion
companion={companion}
@@ -13,13 +13,14 @@
* - Eye gaze is driven imperatively via ref (no React rerenders for gaze)
*/
import { useMemo, memo, type RefObject } from 'react';
import { useMemo, useRef, memo, type RefObject } from 'react';
import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
import { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
import { useRecipeFingerprint, useFillLevelUpdate } from '@/blobbi/ui/hooks/useFillLevelUpdate';
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
@@ -55,8 +56,12 @@ interface BlobbiCompanionVisualProps {
// companion rerender storm (~60 renders/s from motion/float RAF loops).
// It renders BlobbiAdultVisual / BlobbiBabyVisual with renderMode="companion".
//
// It MUST only rerender when actual visual content changes:
// blobbi, recipe, recipeLabel, emotion, bodyEffects, stage
// It MUST only rerender when actual visual STRUCTURE changes:
// blobbi, recipeFingerprint, recipeLabel, emotion, bodyEffects, stage
//
// It uses recipeFingerprint (not recipe reference) so that level-only
// changes (e.g. nausea drain) do NOT trigger rerenders. The fill level
// is updated imperatively from BlobbiCompanionVisual via useFillLevelUpdate.
//
// It MUST NOT receive or depend on per-frame values:
// eyeOffset value, floatOffset, isDragging, isWalking, position, animationTime
@@ -69,6 +74,8 @@ interface MemoizedBlobbiVisualProps {
blobbi: Blobbi;
eyeOffsetRef: RefObject<EyeOffset>;
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<HTMLDivElement>(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 (
<div
ref={rootRef}
className={cn('relative', className)}
style={{ width: size, height: size }}
>
@@ -263,6 +290,7 @@ export function BlobbiCompanionVisual({
blobbi={blobbi}
eyeOffsetRef={eyeOffsetRef}
recipe={effectiveRecipe}
recipeFingerprint={recipeFingerprint}
recipeLabel={effectiveRecipeLabel}
emotion={effectiveEmotion}
bodyEffects={effectiveBodyEffects}
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div
className="absolute pointer-events-none"
style={{
left: spawnX,
top: spawnY,
transform: 'translate(-50%, -100%)',
zIndex: 10002,
animation: `vomit-fall ${FALL_DURATION_MS}ms ease-in forwards`,
'--vomit-dx': `${fallDeltaX}px`,
'--vomit-dy': `${fallDeltaY}px`,
} as React.CSSProperties}
>
<VomitDrop />
</div>
);
}
// 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 (
<button
type="button"
className="absolute cursor-pointer pointer-events-auto border-0 bg-transparent p-0"
style={{
left: landX,
top: landY,
transform: 'translate(-50%, -100%)',
zIndex: 9998, // Below companion (10000+)
}}
onClick={handleClick}
aria-label="Clean up puddle"
>
<VomitPuddle />
</button>
);
}
/** Small falling drop — green/yellow teardrop shape via SVG. */
function VomitDrop() {
return (
<svg width="12" height="16" viewBox="0 0 12 16" fill="none" aria-hidden="true">
<path
d="M6 0C6 0 1 7 1 10.5C1 13.5 3.2 15.5 6 15.5C8.8 15.5 11 13.5 11 10.5C11 7 6 0 6 0Z"
fill="#6b9e3a"
opacity="0.9"
/>
<path
d="M6 2C6 2 3 7.5 3 10C3 12 4.3 13.5 6 13.5C7.7 13.5 9 12 9 10C9 7.5 6 2 6 2Z"
fill="#8fbf4a"
opacity="0.5"
/>
</svg>
);
}
/** Landed puddle — small green/yellow splat shape via SVG. */
function VomitPuddle() {
return (
<svg width="28" height="14" viewBox="0 0 28 14" fill="none" aria-hidden="true">
<ellipse cx="14" cy="9" rx="13" ry="5" fill="#5a8a30" opacity="0.7" />
<ellipse cx="14" cy="8" rx="11" ry="4" fill="#6b9e3a" opacity="0.85" />
<ellipse cx="12" cy="7" rx="6" ry="2.5" fill="#8fbf4a" opacity="0.5" />
<ellipse cx="17" cy="8.5" rx="4" ry="2" fill="#8fbf4a" opacity="0.4" />
</svg>
);
}
+346 -74
View File
@@ -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<ShakeReactionPhase>('idle');
const [vomitEvent, setVomitEvent] = useState<VomitEvent | null>(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<ShakeReactionPhase>('idle');
@@ -77,112 +123,338 @@ export function useShakeReaction({
const raf = useRef<number | null>(null);
const prevT = useRef(0);
const dizzyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vomitTimer = useRef<ReturnType<typeof setTimeout> | 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]);
}
@@ -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 });
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* mouthAnchor — Static lookup for Blobbi mouth position ratios.
*
* Returns normalized x/y ratios (01) 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<AdultForm, number> = {
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<string> = 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 };
}
@@ -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);
@@ -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 ────────────────────────────────────────────────────────────────────
@@ -153,7 +153,8 @@ export function BlobbiRoomHero({
<div
className={cn(
'relative transition-all duration-500 pointer-events-none',
'relative transition-all duration-500',
!isEgg && 'pointer-events-none',
interactionReaction?.bodyAnimation,
)}
style={!isSleeping ? {
+7 -9
View File
@@ -21,7 +21,7 @@
* inside the SVG continue running across parent rerenders.
*/
import { useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/adult-blobbi';
import { sanitizeBlobbiSvg } from '@/lib/sanitizeBlobbiSvg';
@@ -31,7 +31,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 BlobbiAdultSvgRendererProps {
@@ -71,9 +71,7 @@ export function BlobbiAdultSvgRenderer({
bodyEffects,
className,
}: BlobbiAdultSvgRendererProps) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
+7 -6
View File
@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: safeSvg }}
/>
+2 -2
View File
@@ -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
</div>
) : (
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;
+119 -3
View File
@@ -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
* `<NoteContent as="span">` 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 (
<NoteContent
key={i}
event={synthetic}
as="span"
disableNoteEmbeds={opts.inlineOnly}
disableMediaEmbeds={opts.inlineOnly}
/>
);
}
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<string, unknown>,
) {
const { children, node: _node, ...rest } = props;
return createElement(Tag, rest, enrichChildren(children, event, opts));
};
}
return {
// Paragraphs render as `<div>` 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<string, unknown>) =>
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 <span>{children}</span>;
}
return (
<a
{...rest}
href={safe}
target="_blank"
rel="noopener noreferrer"
className={cn(
'text-primary no-underline hover:underline break-all',
rest.className as string | undefined,
)}
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
);
},
img: ({ src, alt, node: _node, ...rest }) => {
const safe = typeof src === 'string' ? sanitizeUrl(src) : undefined;
if (!safe) return null;
return <img {...rest} src={safe} alt={alt ?? ''} loading="lazy" />;
},
} 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 (
<div className={className}>
{title && (
@@ -58,8 +174,8 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
className="w-full rounded-xl object-cover max-h-96 mb-6"
/>
)}
<div dir="auto" className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
<Markdown rehypePlugins={[rehypeSanitize]}>
<div dir="auto" className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
<Markdown rehypePlugins={[rehypeSanitize]} components={components}>
{event.content}
</Markdown>
</div>
+2 -2
View File
@@ -113,7 +113,7 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
<div className="px-4 pb-2">
<div className="flex flex-wrap gap-1.5">
{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 (
<button
key={profile.pubkey}
@@ -191,7 +191,7 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
}
function SearchResultItem({ profile, onSelect }: { profile: SearchProfile; onSelect: (p: SearchProfile) => void }) {
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 about = profile.metadata.about;
return (
+1 -1
View File
@@ -117,7 +117,7 @@ export function BadgeAwardCard({ event }: BadgeAwardCardProps) {
function RecipientName({ pubkey }: { pubkey: 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 url = useProfileUrl(pubkey, metadata);
if (author.isLoading) {
+1 -1
View File
@@ -56,7 +56,7 @@ export function BadgeDetailContent({ 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 npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
// Query kind 8 badge award events referencing this badge definition
+2 -1
View File
@@ -1,6 +1,7 @@
import { useMemo, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { buildEmojiMap } from '@/lib/customEmoji';
import { HASHTAG_PATTERN } from '@/lib/hashtag';
import { CustomEmojiImg } from '@/components/CustomEmoji';
/** Regex matching `:shortcode:` patterns in text. */
@@ -19,7 +20,7 @@ type BioToken =
*/
function tokenizeBio(text: string): BioToken[] {
// Match: URLs (http/https) | hashtags (#word)
const regex = /(https?:\/\/[^\s]+)|(#[\p{L}\p{N}_]+)/gu;
const regex = new RegExp(`(https?:\\/\\/[^\\s]+)|(${HASHTAG_PATTERN})`, 'gu');
const result: BioToken[] = [];
let lastIndex = 0;
+17 -1
View File
@@ -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);
+181
View File
@@ -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 `<audio>` element is rendered
* hidden inside the component — callers don't need to thread it
* through the tree.
*
* Returns `null` when no usable recording exists, so the caller can
* spread it into a header/title row without worrying about a
* disabled/broken state.
*
* Adapted from Birdstar's BirdInfoDialog `useSongPlayer` (see
* `~/Projects/birdstar/src/components/BirdInfoDialog.tsx`). The
* iNaturalist fallback from the original is deliberately omitted —
* per the user's request Ditto only uses Wikipedia/Commons.
*/
interface BirdSongPlayerProps {
/**
* Wikipedia article title. We resolve it to an audio file on
* Wikimedia Commons.
*/
title: string | null;
className?: string;
/** Rendered in a surrounding flex row; supply a label for a11y when
* the surrounding header doesn't already describe the subject. */
ariaLabel?: string;
}
export function BirdSongPlayer({ title, className, ariaLabel }: BirdSongPlayerProps) {
const { data: song, isLoading } = useBirdSong(title);
if (isLoading) {
return (
<Skeleton
className={cn('size-10 shrink-0 rounded-full', className)}
aria-hidden
/>
);
}
if (!song) return null;
return (
<BirdSongButton song={song} className={className} ariaLabel={ariaLabel} />
);
}
function BirdSongButton({
song,
className,
ariaLabel,
}: {
song: BirdSong;
className?: string;
ariaLabel?: string;
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// When the song source changes (user navigates to a different
// species while one is playing), reset to the paused state — the
// previous <audio> element unmounts, and we don't want the button
// inheriting a stale `isPlaying=true`.
useEffect(() => {
setIsPlaying(false);
}, [song.audioUrl]);
const toggle = () => {
const el = audioRef.current;
if (!el) return;
if (isPlaying) {
el.pause();
setIsPlaying(false);
} else {
// `play()` returns a Promise that rejects when autoplay is
// blocked or the source fails to load. Swallow the rejection —
// the button stays in the paused state and the user can retry.
el.play().then(
() => setIsPlaying(true),
() => setIsPlaying(false),
);
}
};
return (
<>
<button
type="button"
onClick={toggle}
aria-label={
isPlaying
? `Pause ${ariaLabel ?? 'reference recording'}`
: `Play ${ariaLabel ?? 'reference recording'}`
}
aria-pressed={isPlaying}
title={song.attribution}
className={cn(
'group inline-flex size-10 shrink-0 items-center justify-center rounded-full',
'bg-emerald-500 text-white shadow-md ring-1 ring-emerald-400/40',
'transition-[transform,background-color,box-shadow] duration-200',
'hover:bg-emerald-600 hover:shadow-lg active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2',
'focus-visible:ring-offset-background',
'dark:bg-emerald-400 dark:text-emerald-950 dark:hover:bg-emerald-300',
className,
)}
>
{isPlaying ? (
<EqualiserBars />
) : (
// Nudge the play triangle right by 1px — its centroid sits
// left of its bounding box and would otherwise look
// off-center inside the circle.
<Play className="size-4 translate-x-px fill-current" aria-hidden />
)}
</button>
<audio
ref={audioRef}
src={song.audioUrl}
preload="none"
// Loop the reference recording. Commons bird songs are
// typically a few seconds of a single phrase, and users want
// to hear it repeatedly to compare with what they heard in
// the field. The button (same hit region as the equaliser)
// is the explicit stop.
loop
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
className="hidden"
>
Your browser does not support embedded audio.
</audio>
</>
);
}
/**
* 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 (
<span className="flex h-4 items-end gap-[2px]" aria-hidden>
{delays.map((delay, i) => (
<span
key={i}
className={cn(
'block w-[2px] rounded-full bg-current',
// `origin-bottom` keeps the scaleY transform anchored to
// the baseline so the bar "grows up" rather than
// expanding from its center.
'h-full origin-bottom motion-safe:animate-equaliser-bar',
// Static midpoint height when motion is reduced, so the
// UI still conveys "audio is playing" without movement.
'motion-reduce:scale-y-75',
)}
style={{ animationDelay: delay }}
/>
))}
</span>
);
}
+139
View File
@@ -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 (
<div
className={cn(
'mt-2 flex items-center gap-2 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground',
className,
)}
>
<Bird className="size-4" aria-hidden />
Empty Birdex no confirmed species yet.
</div>
);
}
if (expanded) {
return (
<div className={cn('mt-2', className)}>
<div className="mb-3 flex items-center gap-2">
<Bird className="size-4 text-emerald-600 dark:text-amber-300" aria-hidden />
<h3 className="text-[15px] font-semibold leading-tight">
Birdex
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
{entries.length} species
</span>
</h3>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{entries.map((entry) => (
<BirdexTile
key={entry.entityUri}
entityUri={entry.entityUri}
entityId={entry.entityId}
scientificName={entry.scientificName || undefined}
/>
))}
</div>
</div>
);
}
// 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 (
<div className={cn('mt-2', className)}>
<div className="mb-2 flex items-center gap-2">
<Bird className="size-4 text-emerald-600 dark:text-amber-300" aria-hidden />
<span className="text-[15px] font-semibold leading-tight">
Birdex
</span>
<span className="text-sm text-muted-foreground">
· {entries.length} species
</span>
</div>
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8">
{previewEntries.map((entry) => (
<BirdexTile
key={entry.entityUri}
entityUri={entry.entityUri}
entityId={entry.entityId}
scientificName={entry.scientificName || undefined}
/>
))}
{overflowing && <OverflowTile count={overflowCount} />}
</div>
</div>
);
}
/**
* 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 (
<div
className="flex aspect-square items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 text-muted-foreground"
aria-label={`${count} more species`}
>
<span className="text-xs font-semibold sm:text-sm">
+{count}
</span>
</div>
);
}
+118
View File
@@ -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 = (
<div
className={cn(
'group relative aspect-square overflow-hidden rounded-xl bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 shadow-sm',
'dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40',
!nonInteractive && 'transition-shadow hover:shadow-md focus-visible:shadow-md',
className,
)}
>
{isLoading ? (
<Skeleton className="absolute inset-0 h-full w-full" />
) : thumbnail ? (
<img
src={thumbnail}
alt={commonName}
className={cn(
'absolute inset-0 h-full w-full object-cover',
!nonInteractive && 'motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]',
)}
loading="lazy"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Bird
aria-hidden
strokeWidth={1.5}
className="size-8 text-emerald-700/60 dark:text-amber-300/60"
/>
</div>
)}
{/* 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. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/40 to-transparent pt-6">
<div className="px-2 pb-1.5">
<p className="truncate text-[11px] font-semibold leading-tight text-white drop-shadow sm:text-xs">
{isLoading && !scientificName ? '\u00A0' : commonName}
</p>
{scientificName && scientificName !== commonName && (
<p className="truncate text-[10px] italic leading-tight text-white/80">
{scientificName}
</p>
)}
</div>
</div>
</div>
);
if (nonInteractive) return inner;
return (
<Link
to={`/i/${encodeURIComponent(entityUri)}`}
onClick={(e) => e.stopPropagation()}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
aria-label={commonName}
>
{inner}
</Link>
);
}
+1 -1
View File
@@ -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' : '';
+6 -4
View File
@@ -118,6 +118,7 @@ const KIND_LABELS: Record<number, string> = {
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<Record<number, React.ComponentType<{ className?: strin
8333: Bitcoin,
31124: Egg,
2473: Bird,
12473: Bird,
30621: Stars,
};
@@ -420,7 +422,7 @@ export function CommentContext({ event, className }: CommentContextProps) {
function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string; eventId?: 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 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
+2 -2
View File
@@ -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
+6 -5
View File
@@ -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({
<Avatar shape={avatarShape} className="size-12 shrink-0 mt-0.5">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
{(metadata?.name || metadata?.display_name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
</Link>
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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]);
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -108,7 +108,7 @@ export function EmbeddedPeopleListCard({ event, className, disableHoverCards }:
<div className="flex -space-x-1.5">
{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 (
<Avatar
+1 -1
View File
@@ -457,7 +457,7 @@ export function EncryptedLetterCompact({ event, className }: EncryptedLetterComp
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
+1 -1
View File
@@ -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
+53 -8
View File
@@ -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<HTMLParagraphElement>(null);
const [overflows, setOverflows] = useState(false);
@@ -421,10 +429,25 @@ function WikipediaArticleHeader({ title, url }: { title: string; url: string })
<span>Wikipedia</span>
</div>
{/* Title */}
<h2 className="text-2xl sm:text-3xl font-bold leading-snug mb-1">
{wiki.title}
</h2>
{/* 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. */}
<div className="flex items-start justify-between gap-4">
<h2 className="text-2xl sm:text-3xl font-bold leading-snug mb-1 min-w-0 flex-1">
{wiki.title}
</h2>
<BirdSongPlayer
title={title}
className="mt-1"
ariaLabel={`recording for ${wiki.title}`}
/>
</div>
{/* Description */}
{wiki.description && (
@@ -460,8 +483,15 @@ function WikipediaArticleHeader({ title, url }: { title: string; url: string })
)}
</div>
{/* Footer with Wikipedia link */}
<div className="border-t border-border px-5 py-2.5">
{/* 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. */}
<div className="border-t border-border px-5 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<a
href={wiki.articleUrl}
target="_blank"
@@ -472,6 +502,19 @@ function WikipediaArticleHeader({ title, url }: { title: string; url: string })
<span>Read on Wikipedia</span>
<ExternalLink className="size-3" />
</a>
{song && (
<a
href={song.descriptionUrl}
target="_blank"
rel="noopener noreferrer"
title={song.attribution}
className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Play className="size-3 fill-current" aria-hidden />
<span className="truncate">Recording: {song.attribution}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
)}
</div>
</div>
);
@@ -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<number, string> = {
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]);
+1 -1
View File
@@ -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}` : '';
+2 -2
View File
@@ -1049,7 +1049,7 @@ function PackCard({
<div className="flex -space-x-2">
{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 (
<MiniAvatar
key={pk}
@@ -1103,7 +1103,7 @@ function PackCard({
function AuthorAttribution({ pubkey }: { pubkey: string }) {
const { data: authorData } = useAuthors([pubkey]);
const metadata: NostrMetadata | undefined = authorData?.get(pubkey)?.metadata;
const name = metadata?.name || genUserName(pubkey);
const name = metadata?.name || metadata?.display_name || genUserName(pubkey);
return (
<div className="px-4 py-2 bg-muted/30 border-t border-border flex items-center gap-2">
+4 -4
View File
@@ -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 (
+7 -7
View File
@@ -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 (
<aside className="flex flex-col h-screen sticky top-0 py-3 px-4 w-[300px] shrink-0">
<aside className="hidden sidebar:flex flex-col h-screen sticky top-0 py-3 px-4 w-[300px] lg:w-1/4 lg:max-w-[300px] shrink-0">
{/* Logo */}
<div className="flex items-center px-3 mb-1">
<Link to="/" onClick={scrollToTopIfCurrent('/')}>
@@ -151,7 +151,7 @@ export function LeftSidebar() {
<Avatar shape={currentUserAvatarShape} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
{(metadata?.name || metadata?.display_name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
)}
@@ -161,9 +161,9 @@ export function LeftSidebar() {
) : (
<>
<span className="font-semibold text-sm truncate">
{currentUserEvent && metadata?.name
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name}</EmojifiedText>
: (metadata?.name || genUserName(user.pubkey))}
{currentUserEvent && (metadata?.name || metadata?.display_name)
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name || metadata.display_name || ''}</EmojifiedText>
: (metadata?.name || metadata?.display_name || genUserName(user.pubkey))}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={user.pubkey} className="text-xs text-muted-foreground truncate" />
@@ -305,7 +305,7 @@ export function LeftSidebar() {
</button>
<button onClick={handleLogout} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors">
<LogOut className="size-4" />
<span>Log out @{metadata?.name || genUserName(user.pubkey)}</span>
<span>Log out @{metadata?.name || metadata?.display_name || genUserName(user.pubkey)}</span>
</button>
</div>
</PopoverContent>
+11 -11
View File
@@ -121,7 +121,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
}, []);
const chatSidebar = (
<aside className="hidden xl:flex xl:flex-col xl:w-[340px] xl:shrink-0 h-screen sticky top-0">
<aside className="hidden lg:flex lg:flex-col lg:w-[340px] lg:shrink-0 h-screen sticky top-0">
<LiveStreamChat aTag={aTag} className="h-full" />
</aside>
);
@@ -137,7 +137,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
const detailsBlock = (
<div className="space-y-4">
{/* Author — mobile only (desktop shows it above) */}
<div className="xl:hidden">
<div className="lg:hidden">
<StreamAuthorRow event={event} participants={participants} />
</div>
@@ -179,7 +179,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
return (
<>
<main className="xl:max-sidebar:flex max-sidebar:flex max-sidebar:flex-col max-sidebar:livestream-height max-sidebar:overflow-hidden">
<main className="lg:max-sidebar:flex max-sidebar:flex max-sidebar:flex-col max-sidebar:livestream-height max-sidebar:overflow-hidden">
{/* Header */}
<PageHeader
title="Live Stream"
@@ -194,7 +194,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
</PageHeader>
{/* Video Player */}
<div className="xl:px-4 shrink-0">
<div className="lg:px-4 shrink-0">
{playUrl ? (
<LiveStreamPlayer
src={playUrl}
@@ -203,7 +203,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
title={title}
/>
) : (
<div className="aspect-video xl:rounded-2xl bg-muted flex items-center justify-center border-y xl:border border-border">
<div className="aspect-video lg:rounded-2xl bg-muted flex items-center justify-center border-y lg:border border-border">
<div className="text-center space-y-2">
<Radio className="size-8 text-muted-foreground/40 mx-auto" />
<p className="text-sm text-muted-foreground">
@@ -241,7 +241,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
</div>
{/* Author / Host — desktop only (on mobile it's inside the expandable details) */}
<div className="hidden xl:block">
<div className="hidden lg:block">
<StreamAuthorRow event={event} participants={participants} />
</div>
@@ -249,7 +249,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
{hasExpandable && (
<button
onClick={() => setDescExpanded((v) => !v)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors xl:hidden"
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors lg:hidden"
>
{descExpanded ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
{descExpanded ? 'Hide details' : 'Show details'}
@@ -258,24 +258,24 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
{/* Mobile: collapsible details */}
{descExpanded && (
<div className="xl:hidden">
<div className="lg:hidden">
{detailsBlock}
</div>
)}
{/* Desktop: always show details */}
<div className="hidden xl:block">
<div className="hidden lg:block">
{detailsBlock}
</div>
</div>
{/* Mobile chat — fills remaining viewport, scrollbox sits above bottom nav */}
<div className="xl:hidden mt-2 border-t border-border flex-1 min-h-0 overflow-hidden">
<div className="lg:hidden mt-2 border-t border-border flex-1 min-h-0 overflow-hidden">
<LiveStreamChat aTag={aTag} className="h-full" />
</div>
{/* Bottom spacer (desktop only) */}
<div className="hidden xl:block h-8" />
<div className="hidden lg:block h-8" />
</main>
</>
);
+2 -2
View File
@@ -188,7 +188,7 @@ export function LiveStreamPlayer({ src, poster, className, title, artist }: Live
if (hasError) {
return (
<div className={cn('relative xl:rounded-2xl overflow-hidden bg-black aspect-video flex items-center justify-center', className)}>
<div className={cn('relative lg:rounded-2xl overflow-hidden bg-black aspect-video flex items-center justify-center', className)}>
<div className="text-center space-y-2 px-4">
<p className="text-white/80 text-sm font-medium">Stream unavailable</p>
<p className="text-white/50 text-xs">The live stream could not be loaded. It may have ended or the URL is unreachable.</p>
@@ -201,7 +201,7 @@ export function LiveStreamPlayer({ src, poster, className, title, artist }: Live
<div
ref={containerRef}
className={cn(
'relative xl:rounded-2xl overflow-hidden bg-black group aspect-video',
'relative lg:rounded-2xl overflow-hidden bg-black group aspect-video',
className,
)}
onMouseMove={revealControls}
+3 -5
View File
@@ -44,7 +44,7 @@ function PageSkeleton() {
</div>
</main>
{/* Right sidebar placeholder — preserves layout width */}
<div className="w-[300px] shrink-0 hidden xl:block" />
<div className="w-1/4 max-w-[300px] shrink-0 hidden lg:block" />
</>
);
}
@@ -74,9 +74,7 @@ function MainLayoutInner() {
{/* Main layout - three column on desktop */}
<div className={cn("flex justify-center mx-auto max-w-[1200px]", wrapperClassName)}>
{/* Desktop left sidebar - hidden below sidebar breakpoint */}
<div className="hidden sidebar:block">
<LeftSidebar />
</div>
<LeftSidebar />
{/* Main content + right sidebar: inside Suspense so the left sidebar persists while lazy pages load */}
<Suspense fallback={<PageSkeleton />}>
@@ -107,7 +105,7 @@ function MainLayoutInner() {
)}
</div>
{/* Right sidebar — render page-provided sidebar, or the widget sidebar */}
{rightSidebar ?? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>}
{rightSidebar ?? <Suspense fallback={<div className="w-1/4 max-w-[300px] shrink-0 hidden lg:block" />}><WidgetSidebar /></Suspense>}
</Suspense>
</div>
+1 -1
View File
@@ -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 (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary/20 via-background/40 to-primary/5">
+1 -1
View File
@@ -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;
+5 -5
View File
@@ -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) {
</Avatar>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-semibold text-sm truncate">
{currentUserEvent && metadata?.name
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name}</EmojifiedText>
{currentUserEvent && (metadata?.name || metadata?.display_name)
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name || metadata.display_name || ''}</EmojifiedText>
: displayName}
</span>
{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"
>
<LogOut className="size-5 shrink-0" />
<span>Log out @{metadata?.name || genUserName(user.pubkey)}</span>
<span>Log out @{metadata?.name || metadata?.display_name || genUserName(user.pubkey)}</span>
</button>
</div>
)}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -64,7 +64,7 @@ function ProfileSidebarLabel({ pubkey }: { pubkey: string }) {
return (
<span className="truncate">
{metadata?.display_name || metadata?.name || genUserName(pubkey)}
{metadata?.name || metadata?.display_name || genUserName(pubkey)}
</span>
);
}
+3 -3
View File
@@ -20,7 +20,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
const pool = useRef<NPool | undefined>(undefined);
// Use refs so the pool always has the latest data
const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays));
const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays));
// Stable ref to the current user's signer for NIP-42 AUTH.
// The `open()` callback reads from this ref when a relay sends an AUTH
@@ -64,8 +64,8 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
// the next natural refetch. Blanket invalidation caused a disruptive
// full-feed rerender ~3s after page load when NostrSync synced relays.
useEffect(() => {
effectiveRelays.current = getEffectiveRelays(config.relayMetadata, config.useAppRelays);
}, [config.relayMetadata, config.useAppRelays]);
effectiveRelays.current = getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays);
}, [config.relayMetadata, config.useAppRelays, config.useUserRelays]);
// Initialize NPool only once
if (!pool.current) {
+8
View File
@@ -333,6 +333,14 @@ export function NostrSync() {
changed = true;
}
if (
encryptedSettings.useUserRelays !== undefined &&
encryptedSettings.useUserRelays !== current.useUserRelays
) {
updates.useUserRelays = encryptedSettings.useUserRelays;
changed = true;
}
if (encryptedSettings.feedSettings) {
const currentFeed = current.feedSettings;
const remoteFeed = encryptedSettings.feedSettings;
+6 -1
View File
@@ -62,6 +62,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";
@@ -392,6 +393,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;
@@ -448,6 +450,7 @@ export const NoteCard = memo(function NoteCard({
!isFoundLog &&
!isColor &&
!isBirdDetection &&
!isBirdex &&
!isConstellation &&
!isPeopleList &&
!isArticle &&
@@ -603,6 +606,8 @@ export const NoteCard = memo(function NoteCard({
<ColorMomentContent event={event} />
) : isBirdDetection ? (
<BirdDetectionContent event={event} />
) : isBirdex ? (
<BirdexContent event={event} />
) : isConstellation ? (
<ConstellationContent event={event} />
) : isPeopleList ? (
@@ -1907,7 +1912,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 (
+26
View File
@@ -102,6 +102,32 @@ describe('NoteContent', () => {
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
});
it('renders hashtags containing internal hyphens as a single link', async () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
// `#70-706` is a full hashtag; `#nostr-` has a trailing hyphen that should be excluded.
content: 'Reporte #70-706 from #nostr- community.',
sig: 'test-sig',
};
render(
<TestApp>
<NoteContent event={event} />
</TestApp>
);
const codeHashtag = await screen.findByRole('link', { name: '#70-706' });
expect(codeHashtag).toHaveAttribute('href', '/t/70-706');
// Trailing hyphen must not be captured into the hashtag.
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
expect(nostrHashtag).toHaveAttribute('href', '/t/nostr');
});
it('generates deterministic names for users without metadata and styles them differently', async () => {
// Use a valid npub for testing
const event: NostrEvent = {
+17 -5
View File
@@ -24,6 +24,7 @@ import { COUNTRIES } from '@/lib/countries';
import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls';
import { parseImetaMap } from '@/lib/imeta';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { HASHTAG_PATTERN } from '@/lib/hashtag';
import { cn } from '@/lib/utils';
import type { AddrCoords } from '@/hooks/useEvent';
@@ -41,6 +42,9 @@ interface NoteContentProps {
* whitespace) while link preview cards and other non-media embeds are preserved.
* Used inside embedded quote cards to keep them lightweight. */
disableMediaEmbeds?: boolean;
/** Root wrapper element. Defaults to `'div'`. Use `'span'` when embedding
* inside an already-block container (e.g. inside a markdown `<p>`). */
as?: 'div' | 'span';
}
/** Regex matching `:shortcode:` patterns in text. */
@@ -250,13 +254,21 @@ export function NoteContent({
hideEmbedImages = false,
disableNoteEmbeds = false,
disableMediaEmbeds = false,
as: Wrapper = 'div',
}: NoteContentProps) {
const tokens = useMemo(() => {
const text = event.content;
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
const regex = new RegExp(
'(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)'
+ '|((?:https?|wss?):\\/\\/[^\\s]+)'
+ '|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)'
+ '|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)'
+ `|(${HASHTAG_PATTERN})`,
'giu',
);
const result: ContentToken[] = [];
let lastIndex = 0;
@@ -593,7 +605,7 @@ export function NoteContent({
}, [groupedTokens]);
return (
<div dir="auto" className={cn('whitespace-pre-wrap break-words overflow-hidden', className, isEmojiOnly && 'text-5xl leading-tight')}>
<Wrapper dir="auto" className={cn('whitespace-pre-wrap break-words overflow-hidden', className, isEmojiOnly && 'text-5xl leading-tight')}>
{groupedTokens.map((token, i) => {
switch (token.type) {
case 'text':
@@ -804,7 +816,7 @@ export function NoteContent({
onPrev={goPrev}
/>
)}
</div>
</Wrapper>
);
}
@@ -836,8 +848,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 (
+1 -1
View File
@@ -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();
+213 -22
View File
@@ -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 (
<div className="px-4 py-6 flex flex-col items-center text-center gap-3">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<AlertTriangle className="size-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold">Bitcoin zaps aren't available</p>
<p className="text-xs text-muted-foreground max-w-xs">
{message}
</p>
</div>
</div>
<UnsupportedSignerQR
recipientAddress={recipientAddress}
truncatedRecipient={truncatedRecipient}
amountSats={amountSats}
btcPrice={btcPrice}
usdAmount={usdAmount}
setUsdAmount={setUsdAmount}
loginType={loginType}
onClose={onSuccess}
/>
);
}
@@ -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 (
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
<p className="text-xs text-muted-foreground">
{explanation} You can still zap by scanning this QR from any Bitcoin wallet.
</p>
{/* Amount presets (USD) */}
<ToggleGroup
type="single"
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(v) => { if (v) setUsdAmount(Number(v)); }}
className="grid grid-cols-5 gap-1 w-full"
>
{USD_PRESETS.map((v) => (
<ToggleGroupItem
key={v}
value={String(v)}
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
>
<span className="font-semibold">${v}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
<Input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder="Custom amount (USD)"
value={usdAmount}
onChange={(e) => setUsdAmount(e.target.value)}
className="pl-6"
/>
</div>
{/* QR / placeholder */}
<div className="flex justify-center">
{hasAmount && bip21 ? (
<div className="bg-white p-3 rounded-xl" aria-label="Bitcoin payment QR code">
<QRCodeCanvas value={bip21} size={220} level="M" className="block" />
</div>
) : (
<div className="size-[220px] rounded-xl border border-dashed flex items-center justify-center text-xs text-muted-foreground text-center px-4">
{btcPrice
? 'Choose an amount above to generate a payment QR.'
: 'Loading BTC price…'}
</div>
)}
</div>
{/* Amount summary */}
{hasAmount && btcPrice && (
<div className="text-center text-sm">
<span className="font-medium">
{currentUsd > 0 ? `$${currentUsd}` : ''}
</span>
<span className="text-muted-foreground">
{' · '}{formatSats(amountSats)} sats
</span>
</div>
)}
{/* Recipient */}
{recipientAddress && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 min-w-0">
<Bitcoin className="size-3.5 text-orange-500 shrink-0" />
<span className="shrink-0">To:</span>
<span className="font-mono truncate" title={recipientAddress}>{truncatedRecipient}</span>
</div>
</div>
)}
{/* Copy buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(recipientAddress, 'address', 'Address')}
disabled={!recipientAddress}
className="text-xs"
>
{copied === 'address' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
Copy address
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(bip21, 'uri', 'Payment link')}
disabled={!hasAmount || !bip21}
className="text-xs"
>
{copied === 'uri' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
Copy link
</Button>
</div>
{/* Warning: no kind 8333 will be published */}
<Alert>
<AlertTriangle className="size-4" />
<AlertDescription className="text-xs">
Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin.
</AlertDescription>
</Alert>
{onClose && (
<Button type="button" variant="secondary" onClick={onClose} className="w-full">
Done
</Button>
)}
</div>
);
}
+1 -1
View File
@@ -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 (
<Tooltip key={pk}>
+3 -5
View File
@@ -16,6 +16,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { resizeImage } from '@/lib/resizeImage';
import { extractHashtags } from '@/lib/hashtag';
import { cn } from '@/lib/utils';
const MAX_CAPTION_CHARS = 2000;
@@ -226,11 +227,8 @@ export function PhotoComposeModal({ open, onOpenChange, onSuccess }: PhotoCompos
// Extract hashtags from caption
const captionText = caption.trim();
const hashtagMatches = captionText.match(/#[\p{L}\p{N}_]+/gu);
if (hashtagMatches) {
for (const tag of hashtagMatches) {
tags.push(['t', tag.slice(1).toLowerCase()]);
}
for (const tag of extractHashtags(captionText)) {
tags.push(['t', tag]);
}
// Content warning
+2 -2
View File
@@ -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 (
<Avatar key={vote.pubkey} shape={avatarShape} className="size-5 ring-1 ring-background">
<AvatarImage src={metadata?.picture} alt={name} />
@@ -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 }),
+1 -1
View File
@@ -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 });
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -535,7 +535,7 @@ export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }:
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
return (
<aside className={cn("w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
<aside className={cn("w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
{/* Media Section — only shown when pubkey prop is provided */}
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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 (
+40 -4
View File
@@ -167,6 +167,26 @@ export function RelayListManager() {
});
};
const handleToggleUserRelays = async (enabled: boolean) => {
// Update local settings immediately
updateConfig((current) => ({
...current,
useUserRelays: enabled,
}));
// Sync to encrypted storage if logged in (non-blocking)
if (user) {
updateSettings.mutate({ useUserRelays: enabled });
}
toast({
title: enabled ? 'Your relays enabled' : 'Your relays disabled',
description: enabled
? 'Your personal relays will be used alongside app relays when enabled.'
: 'Your personal relays will not be used. Only app relays will be queried.',
});
};
const handleAddRelay = () => {
if (!isValidRelayUrl(newRelayUrl)) {
toast({
@@ -325,17 +345,33 @@ export function RelayListManager() {
{/* User Relays Section */}
<div className="pb-4 pt-4">
<div className="px-3 space-y-3">
<h3 className="text-sm font-medium flex items-center gap-1.5">Your Relays <HelpTip faqId="what-are-relays" iconSize="size-3.5" /></h3>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-1.5">Your Relays <HelpTip faqId="what-are-relays" iconSize="size-3.5" /></h3>
<div className="flex items-center gap-2">
<Label htmlFor="use-user-relays" className="text-xs text-muted-foreground cursor-pointer">
{config.useUserRelays ? 'Enabled' : 'Disabled'}
</Label>
<Switch
id="use-user-relays"
checked={config.useUserRelays}
onCheckedChange={handleToggleUserRelays}
className="scale-90"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Your personal relay list. These are synced to Nostr when logged in.
Your personal relay list. Disabled by default enable to include your relays in queries and publishes. {user ? 'Your list is still synced to Nostr when logged in.' : 'Log in to sync your list to Nostr.'}
</p>
</div>
{/* Relay List */}
<div className="mt-3">
<div className={cn(
"mt-3 transition-opacity",
!config.useUserRelays && "opacity-40"
)}>
{relays.length === 0 ? (
<div className="text-xs text-muted-foreground py-8 text-center">
No personal relays configured. Add relays below or enable App Relays above.
No personal relays configured. Add relays below{config.useAppRelays ? ' or keep App Relays enabled above' : ''}.
</div>
) : (
<div className="space-y-1">
+1 -1
View File
@@ -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) {
+4 -4
View File
@@ -71,7 +71,7 @@ export function RightSidebar() {
const { data: sparklineData, isLoading: sparklinesLoading } = useTagSparklines(visibleTags, labelCreatedAt, isXl && visibleTags.length > 0);
return (
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
<aside className="w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
{/* Trending Tags */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
@@ -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]);
+2 -2
View File
@@ -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 (
<span className="inline-flex items-center gap-1.5 pl-1.5 pr-1 py-0.5 rounded-full bg-secondary border border-border text-xs max-w-[160px]">
@@ -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]);
+1 -1
View File
@@ -157,7 +157,7 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
<div className="flex -space-x-2">
{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 (
<Avatar key={pk} shape={shape} className="size-8 ring-2 ring-background">
+1 -1
View File
@@ -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]);
+1 -1
View File
@@ -201,7 +201,7 @@ export function WidgetSidebar() {
}, [updateWidgets]);
return (
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2">
<aside className="w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
<div className="space-y-2 flex-1">
+6 -9
View File
@@ -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();
+1 -1
View File
@@ -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 (
+3 -3
View File
@@ -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 (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-2xl bg-muted/60 min-w-0">
@@ -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]);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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.)
+1 -1
View File
@@ -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}`);
+1 -1
View File
@@ -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(() => {
+1 -1
View File
@@ -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]);
+9
View File
@@ -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) */
@@ -222,6 +224,13 @@ export interface AppConfig {
relayMetadata: RelayMetadata;
/** Whether to use app default relays in addition to user relays */
useAppRelays: boolean;
/**
* Whether to include the user's personal NIP-65 relay list in the effective relay set.
* Defaults to `false` — users must opt-in via Settings → Network to actually connect
* to their own relays. Until enabled, only the app-default relays are used (assuming
* `useAppRelays` is true).
*/
useUserRelays: boolean;
/** Feed and sidebar content settings */
feedSettings: FeedSettings;
/** Ordered list of sidebar item IDs (built-in + extra-kind). */
+228
View File
@@ -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 `<audio>` compat. Attribution comes from `extmetadata`
* (Artist, LicenseShortName).
*
* Returns `null` when the article has no usable audio — the UI should
* hide the player entirely in that case rather than rendering a
* broken `<audio>` element.
*/
export interface BirdSong {
/** Direct URL to the audio file. Prefer MP3 transcodes when OGG
* originals have one, for Safari/iOS compatibility. Always HTTPS —
* passes through sanitizeUrl before landing here. */
audioUrl: string;
/** Permalink to the Wikimedia Commons file-description page so
* curious users can see the uploader, locality, and license, and
* so license-compliant attribution has a clickable verification
* target. */
descriptionUrl: string;
/** Human-readable attribution line, e.g. "© Jane Doe, CC BY-SA 3.0".
* Shown in the UI to credit the recordist. */
attribution: string;
}
interface MediaListItem {
title: string;
type?: 'audio' | 'image' | 'video';
/** "generic" for field recordings, "spoken" for spoken-article
* narrations. We only want "generic". */
audio_type?: 'generic' | 'spoken';
}
interface MediaListResponse {
items?: MediaListItem[];
}
interface VideoInfoDerivative {
src: string;
type: string;
transcodekey?: string;
}
interface ExtMetadataField {
value: string;
}
interface VideoInfo {
url?: string;
descriptionurl?: string;
mime?: string;
derivatives?: VideoInfoDerivative[];
extmetadata?: {
Artist?: ExtMetadataField;
LicenseShortName?: ExtMetadataField;
Credit?: ExtMetadataField;
};
}
interface VideoInfoResponse {
query?: {
pages?: Record<string, { videoinfo?: VideoInfo[] }>;
};
}
/**
* Find the first non-spoken `audio` item on the article. The media-
* list endpoint redirects transparently for scientific-name lookups
* so we don't need a separate title-resolution step.
*/
async function fetchFirstAudioFile(
title: string,
signal?: AbortSignal,
): Promise<string | null> {
const url = `https://en.wikipedia.org/api/rest_v1/page/media-list/${encodeURIComponent(
title.replace(/\s+/g, '_'),
)}`;
const res = await fetch(url, {
signal,
headers: { accept: 'application/json' },
});
if (!res.ok) return null;
const data = (await res.json()) as MediaListResponse;
for (const item of data.items ?? []) {
if (item.type !== 'audio') continue;
// Drop Wikipedia spoken-article recordings — these are
// encyclopaedia narrations, not field recordings.
if (item.audio_type === 'spoken') continue;
return item.title;
}
return null;
}
/**
* Look up `videoinfo` for a Commons file title. The endpoint name is
* a legacy artifact; `videoinfo` is the unified property for any
* transcodable media including audio.
*/
async function fetchFileSong(
fileTitle: string,
signal?: AbortSignal,
): Promise<BirdSong | null> {
const url = new URL('https://en.wikipedia.org/w/api.php');
url.searchParams.set('action', 'query');
url.searchParams.set('format', 'json');
url.searchParams.set('prop', 'videoinfo');
url.searchParams.set('viprop', 'url|mime|derivatives|extmetadata');
url.searchParams.set('titles', fileTitle);
// `origin=*` unlocks anonymous CORS for action API requests.
url.searchParams.set('origin', '*');
const res = await fetch(url.toString(), {
signal,
headers: { accept: 'application/json' },
});
if (!res.ok) return null;
const data = (await res.json()) as VideoInfoResponse;
const pages = data.query?.pages;
if (!pages) return null;
const page = Object.values(pages)[0];
const info = page?.videoinfo?.[0];
if (!info) return null;
// Prefer the MP3 transcode for Safari/iOS compat. Fall back to the
// original URL when no derivative exists (already-MP3 or WAV
// sources don't get transcoded).
const mp3 = info.derivatives?.find((d) => d.type.startsWith('audio/mpeg'));
const rawAudioUrl = mp3?.src ?? info.url;
const audioUrl = sanitizeUrl(rawAudioUrl);
if (!audioUrl) return null;
const descriptionUrl =
sanitizeUrl(info.descriptionurl) ??
`https://commons.wikimedia.org/wiki/${encodeURIComponent(fileTitle)}`;
const artist = htmlToText(info.extmetadata?.Artist?.value);
const license = htmlToText(info.extmetadata?.LicenseShortName?.value);
const credit = htmlToText(info.extmetadata?.Credit?.value);
const attribution = buildAttribution(artist, license, credit);
return { audioUrl, descriptionUrl, attribution };
}
/**
* Strip HTML tags from an extmetadata value. Wikipedia wraps these in
* inline `<a>` links which render as literal markup if we don't.
* DOMParser decodes HTML entities correctly on both browsers and
* jsdom (used in tests).
*/
function htmlToText(html: string | undefined): string | null {
if (!html) return null;
if (typeof DOMParser === 'undefined') {
const stripped = html.replace(/<[^>]*>/g, '').trim();
return stripped || null;
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const text = (doc.body.textContent ?? '').trim();
return text || null;
}
/**
* Compose an attribution string roughly matching how the UI in
* Birdstar formats it: "© {artist}, {license}" when possible,
* degrading gracefully as fields go missing. Falls back to a
* generic "Wikimedia Commons" so the link target still has a label.
*/
function buildAttribution(
artist: string | null,
license: string | null,
credit: string | null,
): string {
if (artist && license) return `© ${artist}, ${license}`;
if (artist) return `© ${artist}`;
if (license) return license;
if (credit) return credit;
return 'Wikimedia Commons';
}
async function fetchBirdSong(
title: string,
signal?: AbortSignal,
): Promise<BirdSong | null> {
const file = await fetchFirstAudioFile(title, signal);
if (!file) return null;
return fetchFileSong(file, signal);
}
/**
* Resolve a reference recording (song, call) from a Wikipedia article
* title. Returns `null` when the article has no usable audio so the
* UI can hide the player rather than rendering a broken element.
*
* Recordings don't change often, so results cache for 24h in the
* TanStack query layer — repeat visits to the same species page are
* instant.
*/
export function useBirdSong(title: string | null) {
return useQuery<BirdSong | null>({
queryKey: ['bird-song', title],
enabled: Boolean(title),
staleTime: 1000 * 60 * 60 * 24,
gcTime: 1000 * 60 * 60 * 24,
retry: 1,
queryFn: ({ signal }) => fetchBirdSong(title!, signal),
});
}
+2
View File
@@ -52,6 +52,8 @@ export interface EncryptedSettings {
autoShareTheme?: boolean;
/** Whether to use app default relays in addition to user relays */
useAppRelays?: boolean;
/** Whether to include the user's personal NIP-65 relay list in the effective relay set. */
useUserRelays?: boolean;
/** Feed and sidebar content settings */
feedSettings?: FeedSettings;
/** Advanced content filters */
+3
View File
@@ -216,6 +216,9 @@ export function useInitialSync() {
if (parsed.useAppRelays !== undefined) {
updates.useAppRelays = parsed.useAppRelays;
}
if (parsed.useUserRelays !== undefined) {
updates.useUserRelays = parsed.useUserRelays;
}
if (parsed.feedSettings) {
updates.feedSettings = {
...current.feedSettings,
+2 -2
View File
@@ -77,7 +77,7 @@ export function useNativeNotifications(): void {
return;
}
const effectiveRelays = getEffectiveRelays(config.relayMetadata, config.useAppRelays);
const effectiveRelays = getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays);
const relayUrls = effectiveRelays.relays
.filter((r) => r.read)
.map((r) => r.url);
@@ -91,5 +91,5 @@ export function useNativeNotifications(): void {
notificationStyle,
...(authorsFilter ? { authors: authorsFilter } : {}),
});
}, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled, notificationStyle, enabledKinds, authorsFilter]);
}, [user, config.relayMetadata, config.useAppRelays, config.useUserRelays, notificationsEnabled, notificationStyle, enabledKinds, authorsFilter]);
}
+2 -2
View File
@@ -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);
});
+1 -1
View File
@@ -115,7 +115,7 @@ export function useWebxdc(uuid: string): WebxdcAPI<unknown> {
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<typeof ephemeralSigner.signEvent>[0]) => {
+5
View File
@@ -901,3 +901,8 @@
100% { transform: scale(2.2); opacity: 0; }
}
@keyframes vomit-fall {
0% { transform: translate(-50%, -100%) translate(0, 0) scale(1); opacity: 1; }
100% { transform: translate(-50%, -100%) translate(var(--vomit-dx), var(--vomit-dy)) scale(1); opacity: 1; }
}
+13 -25
View File
@@ -37,22 +37,26 @@ export const APP_RELAYS: RelayMetadata = {
/**
* Get the effective relay list based on user settings.
* Combines app relays with user relays if useAppRelays is true,
* otherwise returns only user relays.
*
* - `useAppRelays`: when true, the app-default relays are included (first).
* - `useUserRelays`: when true, the user's personal NIP-65 list is included.
*
* When both flags are off the result is empty. When both are on the two lists
* are merged with app relays first, deduplicated by normalized URL.
*/
export function getEffectiveRelays(
userRelays: RelayMetadata,
useAppRelays: boolean
useAppRelays: boolean,
useUserRelays: boolean,
): RelayMetadata {
if (!useAppRelays) {
return deduplicateRelays(userRelays);
}
// Merge app relays with user relays, avoiding duplicates by normalized URL
const seen = new Set<string>();
const mergedRelays: RelayMetadata['relays'][number][] = [];
for (const relay of [...APP_RELAYS.relays, ...userRelays.relays]) {
const sources: RelayMetadata['relays'] = [];
if (useAppRelays) sources.push(...APP_RELAYS.relays);
if (useUserRelays) sources.push(...userRelays.relays);
for (const relay of sources) {
const normalized = normalizeUrl(relay.url);
if (!seen.has(normalized)) {
seen.add(normalized);
@@ -65,19 +69,3 @@ export function getEffectiveRelays(
updatedAt: userRelays.updatedAt,
};
}
/** Deduplicate relays within a single list by normalized URL. */
function deduplicateRelays(metadata: RelayMetadata): RelayMetadata {
const seen = new Set<string>();
const relays: RelayMetadata['relays'][number][] = [];
for (const relay of metadata.relays) {
const normalized = normalizeUrl(relay.url);
if (!seen.has(normalized)) {
seen.add(normalized);
relays.push(relay);
}
}
return { relays, updatedAt: metadata.updatedAt };
}
+13
View File
@@ -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<Record<number, ComponentType<{ className?: st
35128: Globe,
30817: CircleAlert,
2473: Bird,
12473: Bird,
30621: Stars,
};
+7
View File
@@ -101,6 +101,13 @@ export function shouldHideFeedEvent(event: NostrEvent): boolean {
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;
}
// 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 ?? ''));
+5 -4
View File
@@ -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);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared hashtag regex pattern used for linkifying content and extracting
* `t` tags from composed posts.
*
* Matches `#` followed by a run of Unicode letters, numbers, and underscores,
* optionally with internal hyphens (e.g. `#70-706`, `#bitcoin-conference`).
* A hashtag must begin and end with a letter/number/underscore leading or
* trailing hyphens are excluded so `#nostr-` captures only `#nostr`.
*
* The pattern is exported as a string (without flags) so it can be embedded
* in larger combined regexes. Use `hashtagRegex()` for a standalone matcher.
*/
export const HASHTAG_PATTERN = '#[\\p{L}\\p{N}_](?:[\\p{L}\\p{N}_-]*[\\p{L}\\p{N}_])?';
/** Return a fresh global+unicode RegExp that matches hashtags. */
export function hashtagRegex(): RegExp {
return new RegExp(HASHTAG_PATTERN, 'gu');
}
/**
* Extract hashtags from content text and return their lowercase `t` tag values
* (without the leading `#`).
*/
export function extractHashtags(content: string): string[] {
return content.match(hashtagRegex())?.map((h) => h.slice(1).toLowerCase()) ?? [];
}
+63
View File
@@ -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<digits> 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<string>();
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;
}
+3
View File
@@ -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(),
});
@@ -229,6 +230,7 @@ export const AppConfigSchema = z.object({
themes: ThemesConfigSchema.optional(),
relayMetadata: RelayMetadataSchema,
useAppRelays: z.boolean(),
useUserRelays: z.boolean(),
feedSettings: FeedSettingsSchema,
sidebarOrder: z.array(z.string()),
nip85StatsPubkey: z.string().refine(
@@ -312,6 +314,7 @@ export const EncryptedSettingsSchema = z.looseObject({
customTheme: ThemeConfigCompatSchema.optional(),
autoShareTheme: z.boolean().optional(),
useAppRelays: z.boolean().optional(),
useUserRelays: z.boolean().optional(),
feedSettings: FeedSettingsSchema.optional(),
contentFilters: z.array(ContentFilterSchema).optional(),
contentWarningPolicy: ContentWarningPolicySchema.optional(),

Some files were not shown because too many files have changed in this diff Show More