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()) + } +}