Render Birdstar Birdex events (kind 12473) as tiled life lists
A Birdex is a replaceable per-author index of every bird species the author has ever confirmed via kind 2473, stored as positional i/n tag pairs in chronological first-seen order. In feeds, show a compact tile strip of the most recently-added species with a "+N" capstone when the list overflows — mirroring how kind 3 follow lists preview members as an avatar stack plus "+N more". On the post-detail page, render every species as a responsive grid so visitors can browse the author's whole life list. Each tile resolves the species' Wikidata entity through English Wikipedia to pull a thumbnail and common-name label, reusing the same fetch path as kind 2473 detection cards. The Wikidata URL is sanitized before being routed, and the paired n tag provides a scientific-name fallback while the remote lookup is in flight.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -114,6 +114,7 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeBlobbi: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
|
||||
@@ -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,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 = (
|
||||
<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. */}
|
||||
<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 && summary?.title && summary.title !== scientificName && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1229,6 +1229,7 @@ const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
|
||||
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]);
|
||||
|
||||
@@ -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({
|
||||
<ColorMomentContent event={event} />
|
||||
) : isBirdDetection ? (
|
||||
<BirdDetectionContent event={event} />
|
||||
) : isBirdex ? (
|
||||
<BirdexContent event={event} />
|
||||
) : isConstellation ? (
|
||||
<ConstellationContent event={event} />
|
||||
) : isPeopleList ? (
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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 ?? ''));
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
|
||||
1617: 'patch',
|
||||
1618: 'pull request',
|
||||
2473: 'bird detection',
|
||||
12473: 'Birdex',
|
||||
3367: 'color moment',
|
||||
7516: 'found log',
|
||||
15128: 'nsite',
|
||||
|
||||
@@ -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 && <FoundLogContent event={event} />}
|
||||
{isColor && <ColorMomentContent event={event} />}
|
||||
{isBirdDetection && <BirdDetectionContent event={event} />}
|
||||
{isBirdex && <BirdexContent event={event} expanded />}
|
||||
{isConstellation && <ConstellationContent event={event} />}
|
||||
{isPeopleList && <PeopleListContent event={event} />}
|
||||
{isEmojiPack && <EmojiPackContent event={event} />}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function TestApp({ children }: TestAppProps) {
|
||||
feedIncludeBlobbi: true,
|
||||
showBirdstar: false,
|
||||
feedIncludeBirdDetections: false,
|
||||
feedIncludeBirdex: false,
|
||||
feedIncludeConstellations: false,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user