diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 59fc275990..101451360d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -3489,6 +3490,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
+ is BirdDetectionEvent -> {
+ consumeRegularEvent(event, relay, wasVerified)
+ }
+
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
index d7cd351841..748e848c32 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
+import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
@@ -229,6 +230,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -1381,6 +1383,10 @@ private fun RenderNoteRow(
RenderBirdex(baseNote)
}
+ is BirdDetectionEvent -> {
+ RenderBirdDetection(baseNote)
+ }
+
is RoadEventReportEvent -> {
RenderRoadEventReport(baseNote)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt
index 5237134276..a73f3a2532 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Birdex.kt
@@ -30,17 +30,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
/** How many species names to list before collapsing into a "+N more" suffix. */
private const val SPECIES_PREVIEW_LIMIT = 6
+/** Bird emoji prefix shared by both Birdstar card titles. */
+private const val BIRD_PREFIX = "\uD83D\uDC26 "
+
/**
* Minimal, fixed-size summary card for a Birdstar "Birdex" (kind 12473).
*
@@ -61,7 +68,7 @@ fun RenderBirdex(baseNote: Note) {
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
- text = pluralStringResource(R.plurals.birdex_species_count, names.size, names.size),
+ text = BIRD_PREFIX + pluralStringResource(R.plurals.birdex_species_count, names.size, names.size),
style = MaterialTheme.typography.titleMedium,
)
@@ -82,3 +89,64 @@ fun RenderBirdex(baseNote: Note) {
}
}
}
+
+/**
+ * Minimal card for a single Birdstar bird detection (kind 2473).
+ *
+ * The event has no body and no images. The title is the common name parsed from
+ * the publisher's `alt` tag (a generic label when absent), and the scientific
+ * name renders as an italic link to the Wikidata species entry from the `i` tag
+ * when one is present. The `g` geohash is surfaced by the generic note-location
+ * UI, not here.
+ */
+@Composable
+fun RenderBirdDetection(baseNote: Note) {
+ val noteEvent = baseNote.event as? BirdDetectionEvent ?: return
+
+ val detection =
+ remember(noteEvent) {
+ BirdDetectionInfo(
+ commonName = noteEvent.commonName(),
+ species = noteEvent.speciesName(),
+ reference = noteEvent.speciesReference(),
+ )
+ }
+
+ Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
+ Text(
+ text = BIRD_PREFIX + (detection.commonName ?: stringResource(R.string.bird_detection_title)),
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ if (detection.species != null) {
+ Spacer(Modifier.height(6.dp))
+ if (detection.reference != null) {
+ val typography = MaterialTheme.typography
+ val speciesStyle = remember(typography) { typography.bodyMedium.copy(fontStyle = FontStyle.Italic) }
+ ClickableUrl(
+ urlText = detection.species,
+ url = detection.reference,
+ style = speciesStyle,
+ )
+ } else {
+ Text(
+ text = detection.species,
+ style = MaterialTheme.typography.bodyMedium,
+ fontStyle = FontStyle.Italic,
+ color = MaterialTheme.colorScheme.placeholderText,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+/** Tag values parsed once per event for the detection card. */
+private class BirdDetectionInfo(
+ val commonName: String?,
+ val species: String?,
+ val reference: String?,
+)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt
index 988d6b12fc..f4821e3315 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
@@ -140,6 +141,7 @@ class FollowPackFeedNewThreadFeedFilter(
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
+ noteEvent is BirdDetectionEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt
index 7e68087aca..5e3597e2cb 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
@@ -129,6 +130,7 @@ class HomeNewThreadFeedFilter(
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
+ noteEvent is BirdDetectionEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt
index cd65087039..2979f555f0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt
@@ -24,6 +24,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
@@ -72,6 +74,8 @@ val HomePostsNewThreadKinds2 =
ChessGameEvent.KIND,
LiveChessGameChallengeEvent.KIND,
LiveChessGameEndEvent.KIND,
+ BirdDetectionEvent.KIND,
+ BirdexEvent.KIND,
)
val HomePostsConversationKinds =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt
index 1db6f91b4b..5ecb3cd102 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt
@@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -74,6 +76,8 @@ val UserProfilePostKinds2 =
ZapPollEvent.KIND,
PinListEvent.KIND,
AttestationEvent.KIND,
+ BirdDetectionEvent.KIND,
+ BirdexEvent.KIND,
)
// NIP-5A nsites (15128/35128) and NIP-5D napplets (15129/35129) the user publishes, surfaced in
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt
index ac353129c5..fbddcc4a63 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
@@ -80,6 +81,7 @@ class UserProfileMutualFeedFilter(
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
+ it.event is BirdDetectionEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt
index 4e65e5bc6a..bc874d4425 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
@@ -86,6 +87,7 @@ class UserProfileNewThreadFeedFilter(
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
+ it.event is BirdDetectionEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
index 85c84cfa69..e452ea6032 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
@@ -155,6 +155,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
+import com.vitorpamplona.amethyst.ui.note.types.RenderBirdDetection
import com.vitorpamplona.amethyst.ui.note.types.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
@@ -249,6 +250,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -972,6 +974,8 @@ private fun FullBleedNoteCompose(
RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav)
} else if (noteEvent is BirdexEvent) {
RenderBirdex(baseNote)
+ } else if (noteEvent is BirdDetectionEvent) {
+ RenderBirdDetection(baseNote)
} else if (noteEvent is RoadEventReportEvent) {
RenderRoadEventReport(baseNote)
} else if (noteEvent is RoadEventConfirmationEvent) {
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 38c36cf6ab..96d1287e7d 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -3485,6 +3485,7 @@
%1$s funded of %2$s sats goal
Ends %1$s
On-chain donation
+ Bird detection
- Birdex · %1$d species
- Birdex · %1$d species
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt
new file mode 100644
index 0000000000..a45eef32cd
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEvent.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.experimental.birdstar
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
+import com.vitorpamplona.quartz.nip31Alts.alt
+import com.vitorpamplona.quartz.nip50Search.SearchableEvent
+
+/**
+ * Birdstar bird detection (kind 2473).
+ *
+ * An app-specific **regular** kind published by the Birdstar app
+ * (`birdstar.app`) — a single bird sighting/detection. It is **not** defined by
+ * any NIP; the schema below is derived from events seen in the wild. It is the
+ * per-sighting companion of the replaceable [BirdexEvent] (kind 12473) life list.
+ *
+ * The event carries no body — its payload is in the tags:
+ *
+ * - `n` — the detected species' scientific name (e.g. `Porphyrio martinica`).
+ * - `i` — an external identity reference for the species, a Wikidata entity URL
+ * (NIP-73 style, e.g. `https://www.wikidata.org/entity/Q27074644`).
+ * - `g` — a geohash of the detection location (standard NIP-01 `g` tag; use
+ * `Event.geohashes()` to read it).
+ * - `alt` — a human-readable summary written by the publisher
+ * (e.g. `Bird detection: Purple Gallinule (Porphyrio martinica)`).
+ */
+@Immutable
+class BirdDetectionEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: Array>,
+ content: String,
+ sig: HexKey,
+) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
+ SearchableEvent {
+ override fun indexableContent() = listOfNotNull(summary(), speciesName()).joinToString("\n")
+
+ /** Scientific name of the detected species, from the `n` tag (may be null). */
+ fun speciesName() = tags.firstTagValue("n")
+
+ /**
+ * External species reference (Wikidata entity URL), from the `i` tag.
+ * Only http(s) URLs are returned — UIs render this as a clickable link,
+ * so other schemes are rejected here rather than at every call site.
+ */
+ fun speciesReference() = tags.firstTagValue("i")?.takeIf { it.startsWith("https://") || it.startsWith("http://") }
+
+ /** Publisher-provided human-readable summary, from the NIP-31 `alt` tag (may be null). */
+ fun summary() = tags.alt()
+
+ /**
+ * Common (vernacular) species name, parsed out of the `alt` tag. Birdstar
+ * currently writes `Bird detection: ()` (in
+ * English); this strips the fixed prefix and the trailing parenthetical.
+ * Null when the `alt` tag is missing or nothing is left after stripping —
+ * including if a future Birdstar release rewords the alt text.
+ */
+ fun commonName(): String? {
+ val alt = summary() ?: return null
+ if (!alt.startsWith(ALT_PREFIX)) return null
+ return alt
+ .removePrefix(ALT_PREFIX)
+ .substringBeforeLast(" (")
+ .trim()
+ .ifBlank { null }
+ }
+
+ companion object {
+ const val KIND = 2473
+ private const val ALT_PREFIX = "Bird detection:"
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt
index 254635bfdc..5d15bb0896 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdexEvent.kt
@@ -23,8 +23,8 @@ package com.vitorpamplona.quartz.experimental.birdstar
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
-import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
+import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
/**
@@ -65,8 +65,8 @@ class BirdexEvent(
/** Number of collected species (one `n` tag per species). */
fun speciesCount() = speciesNames().size
- /** Publisher-provided human-readable summary, from the `alt` tag (may be null). */
- fun summary() = tags.firstTagValue("alt")
+ /** Publisher-provided human-readable summary, from the NIP-31 `alt` tag (may be null). */
+ fun summary() = tags.alt()
companion object {
const val KIND = 12473
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt
index 5e522ade47..dd58d1936a 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent
@@ -558,6 +559,7 @@ object KindNames {
GoodWikiRelayListEvent.KIND to KindName("Wiki Relays", "51"),
UserGraspListEvent.KIND to KindName("GRASP Servers", "34"),
BirdexEvent.KIND to KindName("Birdex", null),
+ BirdDetectionEvent.KIND to KindName("Bird Detection", null),
NwcInfoEvent.KIND to KindName("NWC Info", "47"),
RelayMembershipListEvent.KIND to KindName("Relay Memberships", "43"),
RootSiteEvent.KIND to KindName("Website Root", "5A"),
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
index 3e3a3081a0..cce8601fa1 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent
import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent
@@ -354,6 +355,7 @@ class EventFactory {
AudioTrackEvent.KIND -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
+ BirdDetectionEvent.KIND -> BirdDetectionEvent(id, pubKey, createdAt, tags, content, sig)
BirdexEvent.KIND -> BirdexEvent(id, pubKey, createdAt, tags, content, sig)
BidEvent.KIND -> BidEvent(id, pubKey, createdAt, tags, content, sig)
BidConfirmationEvent.KIND -> BidConfirmationEvent(id, pubKey, createdAt, tags, content, sig)
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt
new file mode 100644
index 0000000000..a65fb84f29
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/birdstar/BirdDetectionEventTest.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.experimental.birdstar
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
+import com.vitorpamplona.quartz.utils.EventFactory
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class BirdDetectionEventTest {
+ // Real event from the wild (relay.ditto.pub), published by birdstar.app
+ private fun sampleEvent(): Event =
+ EventFactory.create(
+ id = "2b1450b8886c997ff14ab57d46fc5a60929ab8bba3dc0c7f1ebfd5916e41a0cc",
+ pubKey = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd",
+ createdAt = 1_783_089_908L,
+ kind = BirdDetectionEvent.KIND,
+ tags =
+ arrayOf(
+ arrayOf("alt", "Bird detection: Purple Gallinule (Porphyrio martinica)"),
+ arrayOf("i", "https://www.wikidata.org/entity/Q27074644"),
+ arrayOf("n", "Porphyrio martinica"),
+ arrayOf("g", "9vk"),
+ arrayOf("client", "birdstar.app"),
+ ),
+ content = "",
+ sig =
+ "7a30bea2cb4b5b2a826448294bfae072d2948f02e3d259f824c7706fbc1e2a32" +
+ "5661efef9a63d5127e7fc26abbd15df55b98df9f44017f19de91616e97b5cad7",
+ )
+
+ @Test
+ fun factoryBuildsBirdDetectionForKind2473() {
+ assertIs(sampleEvent())
+ }
+
+ @Test
+ fun speciesReferenceRejectsNonHttpValues() {
+ val event: Event =
+ EventFactory.create(
+ id = "00".repeat(32),
+ pubKey = "00".repeat(32),
+ createdAt = 1_783_089_908L,
+ kind = BirdDetectionEvent.KIND,
+ tags = arrayOf(arrayOf("i", "javascript:alert(1)")),
+ content = "",
+ sig = "00".repeat(64),
+ )
+ assertIs(event)
+
+ assertEquals(null, event.speciesReference())
+ }
+
+ @Test
+ fun kind2473IsNowKnown() {
+ assertTrue(EventFactory.isKnownKind(BirdDetectionEvent.KIND), "kind 2473 should be a known kind")
+ }
+
+ @Test
+ fun parsesCommonNameFromAltTag() {
+ val event = sampleEvent()
+ assertIs(event)
+
+ assertEquals("Purple Gallinule", event.commonName())
+ }
+
+ @Test
+ fun commonNameIsNullWithoutAltTag() {
+ val event: Event =
+ EventFactory.create(
+ id = "00".repeat(32),
+ pubKey = "00".repeat(32),
+ createdAt = 1_783_089_908L,
+ kind = BirdDetectionEvent.KIND,
+ tags = arrayOf(arrayOf("n", "Porphyrio martinica")),
+ content = "",
+ sig = "00".repeat(64),
+ )
+ assertIs(event)
+
+ assertEquals(null, event.commonName())
+ }
+
+ @Test
+ fun commonNameIsNullWhenAltLacksTheBirdstarPrefix() {
+ val event: Event =
+ EventFactory.create(
+ id = "00".repeat(32),
+ pubKey = "00".repeat(32),
+ createdAt = 1_783_089_908L,
+ kind = BirdDetectionEvent.KIND,
+ tags = arrayOf(arrayOf("alt", "Arbitrary attacker-controlled title (spoof)")),
+ content = "",
+ sig = "00".repeat(64),
+ )
+ assertIs(event)
+
+ assertEquals(null, event.commonName())
+ }
+
+ @Test
+ fun commonNameHandlesAltWithoutParenthetical() {
+ val event: Event =
+ EventFactory.create(
+ id = "00".repeat(32),
+ pubKey = "00".repeat(32),
+ createdAt = 1_783_089_908L,
+ kind = BirdDetectionEvent.KIND,
+ tags = arrayOf(arrayOf("alt", "Bird detection: Purple Gallinule")),
+ content = "",
+ sig = "00".repeat(64),
+ )
+ assertIs(event)
+
+ assertEquals("Purple Gallinule", event.commonName())
+ }
+
+ @Test
+ fun parsesBirdDetectionFields() {
+ val event = sampleEvent()
+ assertIs(event)
+
+ assertEquals("Porphyrio martinica", event.speciesName())
+ assertEquals("https://www.wikidata.org/entity/Q27074644", event.speciesReference())
+ assertEquals("Bird detection: Purple Gallinule (Porphyrio martinica)", event.summary())
+ assertEquals(listOf("9vk"), event.geohashes())
+ }
+}