diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt index ab6d098e46..71816e68cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag @@ -51,6 +52,7 @@ class PodcastEpisodeEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), PodcastEpisode, + RootScope, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt index 658bb0df39..398bbea906 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip50Search.SearchableEvent @@ -65,6 +66,7 @@ class Podcasting20EpisodeEvent( sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), PodcastEpisode, + RootScope, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt index 8dfb72cb04..e9efd02524 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag @@ -49,7 +50,8 @@ class Podcasting20TrailerEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt new file mode 100644 index 0000000000..8333f24373 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt @@ -0,0 +1,91 @@ +/* + * 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.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies that NIP-22 (kind:1111) comments scope correctly onto podcast episodes of both drafts. + * The app's comment composer always routes through [CommentEvent.replyBuilder], so this exercises + * exactly the path a "reply to this episode" tap takes — proving comments already work end-to-end. + */ +class PodcastCommentScopeTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `episode events declare themselves as NIP-22 comment roots`() { + // Consistency with every other commentable content type (articles, videos, …). + val pc20 = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "E", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val f4 = + signer.sign( + PodcastEpisodeEvent.build("E", "d", listOf(AudioTag("https://x/a.mp3"))), + ) + assertTrue(pc20 is RootScope) + assertTrue(f4 is RootScope) + } + + @Test + fun `comment on a Podcasting 2 point 0 episode roots on its address`() { + val episode = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "Episode", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Addressable root: the comment carries the episode's `a`/`A` address (30054:pubkey:d). + assertTrue(comment.rootAddressIds().contains(episode.addressTag())) + assertTrue(comment.hasRootScopeKind(Podcasting20EpisodeEvent.KIND.toString())) + assertTrue(comment.rootEventIds().contains(episode.id)) + } + + @Test + fun `comment on a NIP-F4 episode roots on its event id`() { + val episode = + signer.sign( + PodcastEpisodeEvent.build("Episode", "notes", listOf(AudioTag("https://x/a.mp3"))), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Regular (non-addressable) root: scoped by event id via the `e`/`E` tag, no address root. + assertTrue(comment.rootEventIds().contains(episode.id)) + assertTrue(comment.hasRootScopeKind(PodcastEpisodeEvent.KIND.toString())) + assertTrue(comment.rootAddressIds().isEmpty()) + } +}