Files
eranos/src/components/BirdDetectionContent.tsx
T
Alex Gleason a6bfd2cb68 Add Birdex chorus button
New BirdexChorusButton plays every species' Wikipedia recording at once
— a dawn chorus for the whole life list. Hides itself when no species
has usable audio, and the feed-card variant swallows clicks so toggling
playback doesn't navigate away from the NoteCard.
2026-05-05 17:24:57 -05:00

209 lines
8.5 KiB
TypeScript

import { useMemo } from 'react';
import { Bird } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { NostrEvent } from '@nostrify/nostrify';
import { BirdSongPlayer } from '@/components/BirdSongPlayer';
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';
/**
* Birdstar kind 2473 — Bird Detection.
*
* The species is identified by a NIP-73 `i`/`k` pair pointing at a Wikidata
* entity URI (e.g. `https://www.wikidata.org/entity/Q26825`). We resolve it
* through Wikidata → English Wikipedia to get a display name, a short
* summary, and a thumbnail image.
*
* Structured data lives in tags; `content` is an optional freeform note.
*/
const WIKIDATA_URL_RE = /^https:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/;
interface BirdDetectionContentProps {
event: NostrEvent;
className?: string;
}
function extractWikidata(tags: string[][]): { id: string; url: string } | null {
// A valid detection pairs an `i` tag with `k: web`. There may be multiple
// i/k pairs in principle; we take the first `i` whose URL matches.
for (const tag of tags) {
if (tag[0] !== 'i') continue;
const value = tag[1];
if (typeof value !== 'string') continue;
const m = value.match(WIKIDATA_URL_RE);
if (m) return { id: m[1], url: value };
}
return null;
}
/** Pull a species label from the `alt` tag: "Bird detection: American Robin (Turdus migratorius)". */
function extractAltSpecies(tags: string[][]): { common?: string; scientific?: string } | null {
const alt = tags.find(([n]) => n === 'alt')?.[1];
if (!alt) return null;
// Match "Bird detection: <Common> (<Scientific>)" — keep the parser loose
// so subtly different NIP-31 prefixes still yield a usable label.
const m = alt.match(/^[^:]*:\s*([^()]+?)\s*(?:\(([^)]+)\))?\s*$/);
if (!m) return { common: alt };
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
// summary (extract + thumbnail) for the title.
const { data: entity, isLoading: entityLoading } = useWikidataEntity(wikidata?.id ?? null);
const wikipediaTitle = entity?.wikipediaTitle ?? null;
const { data: summary, isLoading: summaryLoading } = useWikipediaSummary(wikipediaTitle);
const isLoading = entityLoading || summaryLoading;
// Prefer the Wikipedia page title for the display name when available,
// 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 extract = summary?.extract;
const thumbnail = sanitizeUrl(summary?.thumbnail?.source);
// The whole card routes to Ditto's external-content page for this
// species' Wikidata URL. Other users' kind 2473 detections and
// NIP-22 comments both attach to the same `i`-tag identifier, so
// the discussion thread aggregates naturally across clients.
const discussPath = wikidata ? `/i/${encodeURIComponent(wikidata.url)}` : undefined;
// When the user's own freeform note exists we show it above the
// Wikipedia-derived summary. `content` can be empty per the NIP.
const timeStr = new Date(event.created_at * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
if (!wikidata) {
// Shouldn't happen for a valid kind 2473 (the NIP requires the i tag),
// but render something useful rather than silently dropping the event.
return (
<div className={cn('mt-2 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground', className)}>
Bird detection with no species reference.
</div>
);
}
return (
<div className={cn('mt-2', className)}>
<Link
to={discussPath ?? '#'}
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex">
{/* Thumbnail panel */}
<div className="relative w-32 shrink-0 bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 sm:w-40 dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : thumbnail ? (
<img
src={thumbnail}
alt={commonName}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Bird
aria-hidden
strokeWidth={1.5}
className="size-10 text-emerald-700/60 dark:text-amber-300/60"
/>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute bottom-1.5 left-2 font-mono text-[10px] uppercase tracking-wider text-white/85">
{timeStr}
</div>
</div>
{/* Text panel */}
<div className="flex min-w-0 flex-1 items-start gap-3 p-3.5">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold leading-tight">
{commonName}
</h3>
{scientificName && (
<p className="mt-0.5 truncate text-xs italic text-muted-foreground">
{scientificName}
</p>
)}
</div>
{isLoading ? (
<div className="space-y-1.5 pt-0.5">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
) : extract ? (
<p className="line-clamp-3 text-[13px] leading-relaxed text-muted-foreground">
{extract}
</p>
) : (
<p className="text-xs italic text-muted-foreground/70">
Heard at {new Date(event.created_at * 1000).toLocaleString()}.
</p>
)}
</div>
{/* Reference recording from Wikipedia/Commons, when available.
* `BirdSongPlayer` returns null when the article has no
* usable audio, so the right-hand column collapses
* cleanly and the text reflows across the full card.
* The click handler stops propagation so toggling
* playback doesn't also navigate to `/i/...`. */}
{wikipediaTitle && (
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="shrink-0"
>
<BirdSongPlayer title={wikipediaTitle} ariaLabel={`${commonName} recording`} />
</div>
)}
</div>
</div>
</Link>
{note && (
<p className="mt-2 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
{note}
</p>
)}
</div>
);
}