feat(birdstar): render Birdex species collections (kind 12473)

This commit is contained in:
davotoula
2026-06-07 22:18:29 +02:00
parent 856ff3293b
commit 98ff13b83f
12 changed files with 270 additions and 0 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.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -3371,6 +3372,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is BirdexEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -117,6 +117,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.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
@@ -216,6 +217,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.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@@ -1264,6 +1266,10 @@ private fun RenderNoteRow(
RenderFundraiser(baseNote, makeItShort, accountViewModel, nav)
}
is BirdexEvent -> {
RenderBirdex(baseNote, makeItShort, accountViewModel)
}
is HighlightEvent -> {
RenderHighlight(
baseNote,
@@ -0,0 +1,89 @@
/*
* 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.amethyst.ui.note.types
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
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
/**
* Minimal, fixed-size summary card for a Birdstar "Birdex" (kind 12473).
*
* The event has no body and no images, only a species list. To keep the card
* bounded regardless of how many species a Birdex holds, we show the count and a
* short preview of scientific names with a "+N more" suffix no images, no
* expansion, no network calls. The card is identical in the feed and the opened
* view (it ignores [makeItShort]).
*/
@Composable
fun RenderBirdex(
baseNote: Note,
makeItShort: Boolean,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? BirdexEvent ?: return
val names = remember(noteEvent) { noteEvent.speciesNames() }
val preview = remember(names) { names.take(SPECIES_PREVIEW_LIMIT) }
val remaining = names.size - preview.size
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = pluralStringResource(R.plurals.birdex_species_count, names.size, names.size),
style = MaterialTheme.typography.titleMedium,
)
if (preview.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
val joined = preview.joinToString(", ")
Text(
text =
if (remaining > 0) {
stringRes(R.string.birdex_species_preview_more, joined, remaining.toString())
} else {
joined
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
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.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -68,6 +69,7 @@ class FollowPackFeedNewThreadFeedFilter(
NipTextEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
BirdexEvent.KIND,
LongTextNoteEvent.KIND,
)
}
@@ -137,6 +139,7 @@ class FollowPackFeedNewThreadFeedFilter(
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
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.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -70,6 +71,7 @@ class HomeNewThreadFeedFilter(
WikiNoteEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
BirdexEvent.KIND,
LongTextNoteEvent.KIND,
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
@@ -126,6 +128,7 @@ class HomeNewThreadFeedFilter(
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent is BirdexEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
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.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -78,6 +79,7 @@ class UserProfileMutualFeedFilter(
it.event is TextNoteEvent ||
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
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.BirdexEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -84,6 +85,7 @@ class UserProfileNewThreadFeedFilter(
it.event is CommentEvent ||
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event is BirdexEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
@@ -147,6 +147,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.RenderBirdex
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
@@ -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.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
@@ -769,6 +771,8 @@ private fun FullBleedNoteCompose(
RenderGoal(baseNote, accountViewModel, nav)
} else if (noteEvent is FundraiserEvent) {
RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav)
} else if (noteEvent is BirdexEvent) {
RenderBirdex(baseNote, makeItShort = false, accountViewModel)
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is RelayDiscoveryEvent) {
+5
View File
@@ -3138,6 +3138,11 @@
<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>
<plurals name="birdex_species_count">
<item quantity="one">Birdex · %1$d species</item>
<item quantity="other">Birdex · %1$d species</item>
</plurals>
<string name="birdex_species_preview_more">%1$s +%2$s more</string>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
@@ -0,0 +1,71 @@
/*
* 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.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged
/**
* Birdstar "Birdex" species collection (kind 12473).
*
* An app-specific **replaceable** kind published by the Birdstar app
* (`birdstar.app`) a birdwatching life-list. It is **not** defined by any NIP;
* the schema below is derived from events seen in the wild. Being replaceable,
* each author keeps a single, latest Birdex.
*
* The event carries no body and no images its payload is the species list,
* one entry per observed species, as alternating tags:
*
* - `n` the species' scientific name (e.g. `Icterus galbula`).
* - `i` an external identity reference for the species, a Wikidata entity URL
* (NIP-73 style, e.g. `https://www.wikidata.org/entity/Q805774`).
* - `alt` a human-readable summary written by the publisher
* (e.g. `Birdex: 24 species`).
*
* Amethyst renders a minimal, fixed-size summary card from [speciesNames] and
* [speciesCount]; it does not resolve the Wikidata references to images.
*/
@Immutable
class BirdexEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** Scientific names of the collected species, in event order, from the `n` tags. */
fun speciesNames() = tags.mapValueTagged("n") { it }
/** Number of collected species (one `n` tag per species). */
fun speciesCount() = tags.count { it.size > 1 && it[0] == "n" }
/** Publisher-provided human-readable summary, from the `alt` tag (may be null). */
fun summary() = tags.firstTagValue("alt")
companion object {
const val KIND = 12473
const val ALT_DESCRIPTION = "A Birdex species collection"
}
}
@@ -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.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -341,6 +342,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)
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)
BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,78 @@
/*
* 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.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class BirdexEventTest {
private fun sampleEvent(): Event =
EventFactory.create(
id = "a099d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc",
pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d",
createdAt = 1_780_836_939L,
kind = BirdexEvent.KIND,
tags =
arrayOf(
arrayOf("alt", "Birdex: 3 species"),
arrayOf("i", "https://www.wikidata.org/entity/Q805774"),
arrayOf("n", "Icterus galbula"),
arrayOf("i", "https://www.wikidata.org/entity/Q738534"),
arrayOf("n", "Baeolophus bicolor"),
arrayOf("i", "https://www.wikidata.org/entity/Q829683"),
arrayOf("n", "Mimus polyglottos"),
arrayOf("client", "birdstar.app"),
),
content = "",
sig = "00".repeat(64),
)
@Test
fun factoryBuildsBirdexForKind12473() {
val event = sampleEvent()
assertTrue(
event is BirdexEvent,
"Expected a BirdexEvent but got ${event::class.simpleName}",
)
}
@Test
fun kind12473IsNowKnown() {
assertTrue(EventFactory.isKnownKind(BirdexEvent.KIND), "kind 12473 should be a known kind")
}
@Test
fun parsesBirdexFields() {
val event = sampleEvent()
assertIs<BirdexEvent>(event)
assertEquals(3, event.speciesCount())
assertEquals(
listOf("Icterus galbula", "Baeolophus bicolor", "Mimus polyglottos"),
event.speciesNames(),
)
assertEquals("Birdex: 3 species", event.summary())
}
}