Merge pull request #3473 from davotoula/feat/birdstar-detection-kind-2473

support Birdstar bird detection events (kind 2473)
This commit is contained in:
Vitor Pamplona
2026-07-04 07:30:25 -04:00
committed by GitHub
16 changed files with 351 additions and 4 deletions
@@ -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)
}
@@ -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)
}
@@ -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?,
)
@@ -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()) ||
@@ -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()) ||
@@ -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 =
@@ -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
@@ -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 ||
@@ -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 ||
@@ -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) {
+1
View File
@@ -3485,6 +3485,7 @@
<string name="goal_progress">%1$s funded of %2$s sats goal</string>
<string name="fundraiser_ends">Ends %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donation</string>
<string name="bird_detection_title">Bird detection</string>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d species</item>
<item quantity="other">Birdex · %1$d species</item>
@@ -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<Array<String>>,
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: <Common name> (<Scientific name>)` (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:"
}
}
@@ -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
@@ -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"),
@@ -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)
@@ -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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(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<BirdDetectionEvent>(event)
assertEquals("Purple Gallinule", event.commonName())
}
@Test
fun parsesBirdDetectionFields() {
val event = sampleEvent()
assertIs<BirdDetectionEvent>(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())
}
}