Merge branch 'main' into feat/blobbi-1124-interactions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.11.2",
|
||||
"version": "2.12.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* mouthAnchor — Static lookup for Blobbi mouth position ratios.
|
||||
*
|
||||
* Returns normalized x/y ratios (0–1) relative to the companion container,
|
||||
* already accounting for the internal +0.12 translateY shift applied by
|
||||
* BlobbiCompanionVisual.
|
||||
*
|
||||
* Used to position the vomit drop spawn point at the actual mouth.
|
||||
*/
|
||||
|
||||
import { ADULT_FORMS, type AdultForm } from '@/blobbi/adult-blobbi/types/adult.types';
|
||||
|
||||
// ─── Internal visual wrapper shift (BlobbiCompanionVisual translateY) ────────
|
||||
const VISUAL_Y_OFFSET = 0.12;
|
||||
|
||||
// ─── Baby mouth: controlY = 68 in 100×100 viewBox ───────────────────────────
|
||||
const BABY_MOUTH_Y_RATIO = 68 / 100 + VISUAL_Y_OFFSET;
|
||||
|
||||
// ─── Adult mouths: controlY values in 200×200 viewBox ────────────────────────
|
||||
const ADULT_MOUTH_Y_RATIO: Record<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 ? {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' : '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}` : '';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ?? ''));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()) ?? [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user