diff --git a/NIP.md b/NIP.md index d4cb8996..7c0a1c53 100644 --- a/NIP.md +++ b/NIP.md @@ -18,6 +18,7 @@ These event kinds were created by community contributors and are supported by Di | Kind | Name | Description | Spec | |-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------| | 2473 | Bird Detection | Bird-by-ear observation log (species heard in the wild) | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) | +| 12473 | Birdex | Author's cumulative life list of confirmed bird species | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) | | 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) | | 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) | | 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) | @@ -427,7 +428,7 @@ The following specifications are maintained by their respective authors. Ditto i Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion. -### Birdstar (Kinds 2473, 30621) +### Birdstar (Kinds 2473, 12473, 30621) **Author:** Alex Gleason **Spec:** https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md @@ -436,6 +437,7 @@ Color palette posts capturing 3-6 colors from a beautiful moment, optionally acc Birdstar merges Birdsong Spotter (a bird-by-ear checklist) and Starpoint (an interactive sky map with community constellations) into a single client. - **Kind 2473 — Bird Detection.** A regular event representing a single identified bird observation. The species is identified by a NIP-73 `i`/`k` pair pointing at the species' Wikidata entity URI (e.g. `https://www.wikidata.org/entity/Q26825` for the American Robin). The `content` field holds an optional freeform human note about the detection. Required tags: NIP-31 `alt`, NIP-73 `i` (Wikidata URL) + `k` (`web`). Ditto renders detections as a species card with the Wikipedia thumbnail, common/scientific name, and article summary. +- **Kind 12473 — Birdex.** A replaceable event (one per author) indexing every distinct species the author has ever confirmed via kind 2473. Each species is a positional `i`/`n` pair — the Wikidata entity URI followed immediately by the scientific binomial name — emitted in chronological order of first detection. Ditto renders a Birdex as a tiled grid of species, each tile showing the Wikipedia thumbnail with the common name overlaid. In feeds, only the most recent few tiles are shown with a "+N" capstone mirroring how kind 3 follow lists preview members; the post-detail page shows every species. - **Kind 30621 — Custom Constellation.** An addressable event (`d` tag) representing a single user-drawn star figure. Each `edge` tag (`["edge", from, to]`) references two Hipparcos catalog numbers as decimal strings — e.g. `["edge", "32349", "37279"]` for Sirius → Procyon. Required tags: `d`, `title`, `alt`, and at least one valid `edge`. The `content` field is a freeform description. Ditto renders constellations as a stylized SVG star-map (gnomonically projected onto a tangent plane at the figure's centroid, with stars sized by magnitude) using a bundled Hipparcos catalog that is code-split so the data only loads when a constellation is actually viewed. ### Geocaching (Kinds 37516, 7516) diff --git a/src/App.tsx b/src/App.tsx index f5c80a90..550e8568 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -114,6 +114,7 @@ const hardcodedConfig: AppConfig = { feedIncludeBlobbi: true, showBirdstar: true, feedIncludeBirdDetections: true, + feedIncludeBirdex: true, feedIncludeConstellations: true, followsFeedShowReplies: true, }, diff --git a/src/components/BirdexContent.tsx b/src/components/BirdexContent.tsx new file mode 100644 index 00000000..c91e0877 --- /dev/null +++ b/src/components/BirdexContent.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react'; +import { Bird } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { BirdexTile } from '@/components/BirdexTile'; +import { parseBirdexEvent } from '@/lib/parseBirdex'; +import { cn } from '@/lib/utils'; + +/** + * Birdstar kind 12473 — Birdex (life list). + * + * A replaceable per-author index of every distinct bird species the + * author has ever logged via kind 2473. Each species is a positional + * `i`/`n` pair (Wikidata entity URI + scientific name), emitted in + * chronological order of first detection. + * + * Feed variant: a small tiled preview of the most recently-added + * species plus a "+N" capstone, mirroring how kind 3 follow lists + * render as a compact avatar stack with a "+N more" suffix. Full + * variant: the whole life list laid out as a responsive grid so + * visitors can browse every species the author has ever seen. + */ + +/** Tiles rendered in the compact feed preview before collapsing into "+N". */ +const FEED_PREVIEW_LIMIT = 8; + +interface BirdexContentProps { + event: NostrEvent; + /** + * When true, render every species on the life list instead of the + * truncated feed preview. Used on the post-detail page. + */ + expanded?: boolean; + className?: string; +} + +export function BirdexContent({ event, expanded, className }: BirdexContentProps) { + const entries = useMemo(() => parseBirdexEvent(event), [event]); + + // Empty Birdex — either a malformed event or a newly-published + // placeholder. Render a minimal dashed card so the feed row still + // has a meaningful anchor. + if (entries.length === 0) { + return ( +
+ + Empty Birdex — no confirmed species yet. +
+ ); + } + + if (expanded) { + return ( +
+
+ +

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

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

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

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

+ {scientificName} +

+ )} +
+
+
+ ); + + if (nonInteractive) return inner; + + return ( + e.stopPropagation()} + className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl" + aria-label={commonName} + > + {inner} + + ); +} diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index d858a1a7..cd6c3b8f 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -118,6 +118,7 @@ const KIND_LABELS: Record = { 1617: 'a patch', 1618: 'a pull request', 2473: 'a bird detection', + 12473: 'a Birdex', 3367: 'a color moment', 7516: 'a found log', 15128: 'an nsite', @@ -204,6 +205,7 @@ const KIND_ICONS: Partial = { 35128: 'Nsite', 31124: 'Blobbi', 2473: 'Bird Detection', + 12473: 'Birdex', 30621: 'Constellation', }; @@ -1256,6 +1257,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey if (addr.kind === 15128 || addr.kind === 35128) return Globe; if (addr.kind === 3 || addr.kind === 30000) return Users; if (addr.kind === 2473) return Bird; + if (addr.kind === 12473) return Bird; if (addr.kind === 30621) return Stars; return FileText; }, [kindDef, addr.kind]); diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 06f16caa..3b88f26a 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -58,6 +58,7 @@ import { PeopleListContent } from "@/components/PeopleListContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { BirdDetectionContent } from "@/components/BirdDetectionContent"; +import { BirdexContent } from "@/components/BirdexContent"; import { ConstellationContent } from "@/components/ConstellationContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { NsiteCard } from "@/components/NsiteCard"; @@ -390,6 +391,7 @@ export const NoteCard = memo(function NoteCard({ const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; const isBirdDetection = event.kind === 2473; + const isBirdex = event.kind === 12473; const isConstellation = event.kind === 30621; const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isArticle = event.kind === 30023; @@ -440,6 +442,7 @@ export const NoteCard = memo(function NoteCard({ !isFoundLog && !isColor && !isBirdDetection && + !isBirdex && !isConstellation && !isPeopleList && !isArticle && @@ -595,6 +598,8 @@ export const NoteCard = memo(function NoteCard({ ) : isBirdDetection ? ( + ) : isBirdex ? ( + ) : isConstellation ? ( ) : isPeopleList ? ( diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index fca32852..47b05406 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -158,6 +158,8 @@ export interface FeedSettings { showBirdstar: boolean; /** Include bird detections (kind 2473) in the follows/global feed */ feedIncludeBirdDetections: boolean; + /** Include Birdex life lists (kind 12473) in the follows/global feed */ + feedIncludeBirdex: boolean; /** Include custom constellations (kind 30621) in the follows/global feed */ feedIncludeConstellations: boolean; /** Include replies in the follows feed (default: true) */ diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 81b20e85..48f3cf0e 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -496,6 +496,18 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ blurb: 'Bird-by-ear detections — someone heard a species sing or call, and logged the sighting. Identified by Wikidata entity.', sites: [{ url: 'https://birdstar.app', name: 'Birdstar' }], }, + { + kind: 12473, + id: 'birdex', + feedKey: 'feedIncludeBirdex', + label: 'Birdex', + description: 'Cumulative life list of every species a user has ever identified', + addressable: false, + section: 'whimsy', + feedOnly: true, + blurb: 'Birdex — an author\'s cumulative life list of every species they have ever identified, in chronological order of first detection.', + sites: [{ url: 'https://birdstar.app', name: 'Birdstar' }], + }, { kind: 30621, id: 'constellations', @@ -604,6 +616,7 @@ const KIND_SPECIFIC_ICONS: Partial n === 'i' && typeof v === 'string' && wikidataRe.test(v))) return true; } + // Birdex life lists (kind 12473) with no valid species entries — a + // Birdex is an index over the author's kind 2473 detections, so one + // with zero parseable `i` tags has nothing to show. + if (event.kind === 12473) { + const wikidataRe = /^https:\/\/www\.wikidata\.org\/entity\/Q\d+$/; + if (!event.tags.some(([n, v]) => n === 'i' && typeof v === 'string' && wikidataRe.test(v))) return true; + } // Custom constellations (kind 30621) without any valid edge tags if (event.kind === 30621) { const hasEdge = event.tags.some(([n, from, to]) => n === 'edge' && /^\d+$/.test(from ?? '') && /^\d+$/.test(to ?? '')); diff --git a/src/lib/parseBirdex.ts b/src/lib/parseBirdex.ts new file mode 100644 index 00000000..f18f2862 --- /dev/null +++ b/src/lib/parseBirdex.ts @@ -0,0 +1,63 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Canonical Wikidata entity URI shape used by Birdstar kinds 2473 and 12473. + * https → www.wikidata.org → /entity/Q — no fragment, query, or + * trailing slash. This is the trust boundary; tags that don't match are + * silently skipped. + */ +const WIKIDATA_ENTITY_URI_RE = /^https:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/; + +/** A single species entry on a Birdex life list. */ +export interface BirdexSpeciesEntry { + /** Wikidata entity URI — the canonical species identifier. */ + entityUri: string; + /** Wikidata entity ID (e.g. "Q26825") parsed from the URI. */ + entityId: string; + /** + * Scientific (binomial) name carried by the positionally-paired `n` + * tag. Empty string when the source event omitted the `n` tag (older + * Birdstar events, or events authored by tools predating the + * name-pairing convention). + */ + scientificName: string; +} + +/** + * Walk the tags of a kind 12473 Birdex event in order, pairing each + * valid `i` tag with the immediately-following `n` tag (if present) + * per Birdstar NIP § "Kind 12473 — Birdex". + * + * Pairing is positional: the `n` tag is accepted as this species' + * scientific name only when it is the very next entry in the tag + * array. An `i` tag not followed by an `n` yields an entry with an + * empty `scientificName` — still renderable by Q-id alone. + * + * Deduplication keeps the first occurrence of each URI so the + * chronological first-seen order is preserved even if a malformed + * publisher emits duplicates. The URL-shape regex is the trust + * boundary — no paired `k` tag is consulted (the kind contract + * already guarantees every valid `i` is a Wikidata entity URI). + */ +export function parseBirdexEvent(event: NostrEvent): BirdexSpeciesEntry[] { + const seen = new Set(); + const entries: BirdexSpeciesEntry[] = []; + const tags = event.tags; + for (let i = 0; i < tags.length; i++) { + const tag = tags[i]; + if (tag[0] !== 'i') continue; + const uri = tag[1]; + if (typeof uri !== 'string') continue; + const m = uri.match(WIKIDATA_ENTITY_URI_RE); + if (!m) continue; + if (seen.has(uri)) continue; + seen.add(uri); + + const next = tags[i + 1]; + const scientificName = + next && next[0] === 'n' && typeof next[1] === 'string' ? next[1] : ''; + + entries.push({ entityUri: uri, entityId: m[1], scientificName }); + } + return entries; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index a7340dc2..718d3145 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -185,6 +185,7 @@ export const FeedSettingsSchema = z.looseObject({ feedIncludeBadgeAwards: z.boolean().optional(), showBirdstar: z.boolean().optional(), feedIncludeBirdDetections: z.boolean().optional(), + feedIncludeBirdex: z.boolean().optional(), feedIncludeConstellations: z.boolean().optional(), }); diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index d7ba3199..eb3c4e8b 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -69,6 +69,7 @@ const NOTIFICATION_KIND_NOUNS: Record = { 1617: 'patch', 1618: 'pull request', 2473: 'bird detection', + 12473: 'Birdex', 3367: 'color moment', 7516: 'found log', 15128: 'nsite', diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 8f9d2cec..d66e306f 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -46,6 +46,7 @@ import { PeopleListDetailContent } from "@/components/PeopleListDetailContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { BirdDetectionContent } from "@/components/BirdDetectionContent"; +import { BirdexContent } from "@/components/BirdexContent"; import { ConstellationContent } from "@/components/ConstellationContent"; import { GitRepoCard } from "@/components/GitRepoCard"; import { ImageGallery } from "@/components/ImageGallery"; @@ -164,6 +165,7 @@ function shellTitleForKind(kind?: number): string { if (kind === 0) return "Profile"; if (kind === 31124) return "Blobbi"; if (kind === 2473) return "Bird Detection"; + if (kind === 12473) return "Birdex"; if (kind === 30621) return "Constellation"; return "Post Details"; } @@ -1019,6 +1021,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; const isBirdDetection = event.kind === 2473; + const isBirdex = event.kind === 12473; const isConstellation = event.kind === 30621; const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isEmojiPack = event.kind === 30030; @@ -2199,6 +2202,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { isFoundLog || isColor || isBirdDetection || + isBirdex || isConstellation || isPeopleList || isEmojiPack ? ( @@ -2209,6 +2213,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {isFoundLog && } {isColor && } {isBirdDetection && } + {isBirdex && } {isConstellation && } {isPeopleList && } {isEmojiPack && } diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index a3641877..b702a99d 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -94,6 +94,7 @@ export function TestApp({ children }: TestAppProps) { feedIncludeBlobbi: true, showBirdstar: false, feedIncludeBirdDetections: false, + feedIncludeBirdex: false, feedIncludeConstellations: false, followsFeedShowReplies: true, },