From ab0719fa680cda70ca3cf7ab78f6f1df8edb84e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 22:14:49 +0000 Subject: [PATCH] fix(podcasts): wire podcast kinds into every consumer; close audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 10 audit findings from the post-build review: CRITICAL — non-functional without this: - LocalCache.justConsume had no branches for kind 54 / 10054 / 10064 / 10154, so every podcast event fell through to "Event Not Supported" and was silently dropped. Added the four explicit branches (regular event for PodcastEpisode; replaceable for the other three). HIGH — silent invisibility / broken tap-through: - Home, profile (newthreads + mutual), hashtag, geohash, follow-pack, and notification feed filters didn't recognize PodcastEpisodeEvent / PodcastMetadataEvent. Episodes were invisible everywhere outside the dedicated tab; reactions/zaps on episodes were dropped from the notifications feed. - ThreadFeedView's renderer dispatch had no podcast branch, so tapping a feed card opened a plain text-note view. Added explicit cases that call the new RenderPodcastEpisode / RenderPodcastMetadata composables. - The hashtag / geohash / relay / search REQ kind lists didn't include podcast kinds, so discovery surfaces returned nothing for them. - RelayInformationScreen kind→label map gained podcast entries + 4 new string resources (Podcast Episode, Podcast Show, Authored Podcasts, Favorite Podcasts). - HomeNewThreadFeedFilter.ADDRESSABLE_KINDS gained PodcastMetadataEvent so shows surface alongside music/wiki/long-form on the home feed. HIGH — privacy leak in Quartz: - FavoritePodcastsListEvent.add(isPrivate=true) was passing earlierVersion.tags through untouched, so toggling a previously-public favorite to private left the public p-tag intact. Made both branches symmetric: each removes the entry from the other half before adding to its own. Two regression tests cover the round-trip. MEDIUM — data hygiene: - AuthorTag.parse used to accept ANY non-empty slot-2 string as a role (rendering a stray relay-hint URL as "Role: wss://relay…"). Now validates against the spec-defined {host, cohost, editor} allowlist; unknown values resolve to role=null, preserving the pubkey association. PERF: - PodcastEpisode renderer was allocating a fresh 96-element WaveformData and rebuilding the cover Modifier chain per visible card. Hoisted both to top-level constants (FLAT_WAVEFORM, COVER_IMAGE_MODIFIER, PLAYER_BORDER_MODIFIER) so the whole feed shares one instance. CODE QUALITY: - Extracted PodcastCoverCard as a shared composable used by both renderers (was duplicated byte-identical across PodcastEpisode + PodcastMetadata). - Extracted PodcastFeedLoaded so the Episodes screen and Shows screen share one feed body (was duplicated byte-identical). - Dropped the misleading `group = listOf(singleAssembler)` wrapper in the two FilterAssembler files. - Replaced `mapNotNull { … }.flatten()` with `flatMap { … }` in the Communities sub-assembly (the lambda never returns null). DOCUMENTED: - PODCAST_KINDS "Following" resolution still goes through kind:3 follows, but per NIP-F4 podcasts use their own keypairs tracked via kind:10054. Added an inline comment naming the deferred work — proper fix needs Account-level 10054 integration which is a separate scope. --- .../amethyst/model/LocalCache.kt | 20 ++++ .../subassemblies/SearchPostsByText.kt | 4 + .../ui/note/types/PodcastCoverCard.kt | 72 ++++++++++++ .../amethyst/ui/note/types/PodcastEpisode.kt | 110 ++++-------------- .../amethyst/ui/note/types/PodcastMetadata.kt | 52 +-------- .../dal/FollowPackFeedNewThreadFeedFilter.kt | 5 + .../loggedIn/geohash/dal/GeoHashFeedFilter.kt | 6 +- .../datasource/FilterPostsByGeohash.kt | 4 + .../loggedIn/hashtag/dal/HashtagFeedFilter.kt | 6 +- .../datasource/FilterPostsByHashtags.kt | 4 + .../home/dal/HomeNewThreadFeedFilter.kt | 5 + .../dal/NotificationFeedFilter.kt | 4 + .../podcasts/PodcastEpisodesFeedLoaded.kt | 14 ++- .../loggedIn/podcasts/PodcastsFeedLoaded.kt | 44 +------ .../PodcastEpisodesFilterAssembler.kt | 9 +- .../datasource/PodcastsFilterAssembler.kt | 9 +- .../FilterPodcastEventsByAuthors.kt | 7 ++ .../FilterPodcastEventsByCommunities.kt | 36 +++--- .../mutual/dal/UserProfileMutualFeedFilter.kt | 4 + .../dal/UserProfileNewThreadFeedFilter.kt | 4 + .../relay/datasource/FilterPostsByRelay.kt | 4 + .../loggedIn/relays/RelayInformationScreen.kt | 8 ++ .../loggedIn/threadview/ThreadFeedView.kt | 8 ++ amethyst/src/main/res/values/strings.xml | 4 + .../favorites/FavoritePodcastsListEvent.kt | 28 +++-- .../nipF4Podcasts/metadata/tags/AuthorTag.kt | 7 +- .../FavoritePodcastsListEventTest.kt | 54 +++++++++ 27 files changed, 311 insertions(+), 221 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt 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 ae0555fc0f..a376c7a6e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -260,6 +260,10 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -3397,6 +3401,22 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is PodcastEpisodeEvent -> { + consumeRegularEvent(event, relay, wasVerified) + } + + is PodcastMetadataEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is AuthoredPodcastsEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is FavoritePodcastsListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is LnZapEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 0a02216589..1a6e0be1d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -56,6 +56,8 @@ import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val SearchPostsByTextKinds1 = listOf( @@ -69,6 +71,8 @@ val SearchPostsByTextKinds1 = AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, PinListEvent.KIND, ZapPollEvent.KIND, ChannelCreateEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt new file mode 100644 index 0000000000..9e04361395 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastCoverCard.kt @@ -0,0 +1,72 @@ +/* + * 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.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +// Top-rounded square cover used by both PodcastEpisode and PodcastMetadata renderers. +// Shape + modifier hoisted to constants so they aren't rebuilt on every recomposition. +private val COVER_IMAGE_SHAPE = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) +private val COVER_IMAGE_MODIFIER = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(COVER_IMAGE_SHAPE) + +@Composable +internal fun PodcastCoverCard( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + Box(COVER_IMAGE_MODIFIER) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = COVER_IMAGE_MODIFIER, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, COVER_IMAGE_MODIFIER) }, + onError = { DefaultImageHeader(note, accountViewModel, COVER_IMAGE_MODIFIER) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, COVER_IMAGE_MODIFIER) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index def17de157..65c230a3bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -21,12 +21,9 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -40,29 +37,32 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem -import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent -private val COVER_ASPECT_RATIO = 1f +// NIP-F4 doesn't carry a waveform tag, so every episode renders with the same flat baseline. +// Hoist to a top-level constant so we share one List+WaveformData across the whole +// feed instead of allocating a fresh 96-element list per visible card. +private const val WAVEFORM_SAMPLES = 96 +private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) + +// Bottom-rounded border on the audio player so it visually butts up against the cover's +// top-rounded corners as one card. Constant — keep out of recomposition. +private val PLAYER_BORDER_MODIFIER = + Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) @Composable fun RenderPodcastEpisode( @@ -75,51 +75,20 @@ fun RenderPodcastEpisode( ) { val noteEvent = note.event as? PodcastEpisodeEvent ?: return - PodcastEpisodeBody( - noteEvent = noteEvent, - note = note, - makeItShort = makeItShort, - canPreview = canPreview, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -private fun PodcastEpisodeBody( - noteEvent: PodcastEpisodeEvent, - note: Note, - makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, - accountViewModel: AccountViewModel, - nav: INav, -) { val title = remember(noteEvent) { noteEvent.title() } val image = remember(noteEvent) { noteEvent.image() } val description = remember(noteEvent) { noteEvent.description() } - // Pick the first audio URL. If a publisher provides multiple containers, this is the - // ordering they emitted — clients with codec preferences can extend this later. + // Pick the first audio URL. Publishers may emit multiple containers in their preferred + // order; clients with codec preferences can extend this later. val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } - // Suppress the markdown content block if it's blank (the title + description already - // describe a short episode); otherwise let RichText render it below. - val markdown = - remember(noteEvent) { - noteEvent.content.ifBlank { null } - } + // Suppress the markdown block if blank — title + description already describe a short + // episode. Otherwise hand off to RichText below. + val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } } Column(MaterialTheme.colorScheme.replyModifier) { - PodcastEpisodeCover(image, note, accountViewModel) + PodcastCoverCard(image, note, accountViewModel) firstAudio?.let { audio -> - // No waveform tag in NIP-F4, synthesize a flat baseline. ExoPlayer drives - // actual playback progress; the bars are purely decorative. - val waveform = remember(note.idHex) { flatWaveform() } - val callbackUri = remember(note) { note.toNostrUri() } - val playerBorder = - remember { - Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) - } + val callbackUri = remember(noteEvent) { note.toNostrUri() } Row( Modifier.fillMaxWidth().height(80.dp), @@ -135,7 +104,7 @@ private fun PodcastEpisodeBody( aspectRatio = null, proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url), keepPlaying = false, - waveformData = waveform, + waveformData = FLAT_WAVEFORM, ) { mediaItem -> GetVideoController( mediaItem = mediaItem, @@ -145,8 +114,8 @@ private fun PodcastEpisodeBody( RenderVoicePlayer( mediaItem = mediaItem, controllerState = controller, - waveform = waveform, - borderModifier = playerBorder, + waveform = FLAT_WAVEFORM, + borderModifier = PLAYER_BORDER_MODIFIER, accountViewModel = accountViewModel, ) } @@ -185,7 +154,7 @@ private fun PodcastEpisodeBody( markdown?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } - val callbackUri = remember(note) { note.toNostrUri() } + val callbackUri = remember(noteEvent) { note.toNostrUri() } TranslatableRichTextViewer( content = it, @@ -203,40 +172,3 @@ private fun PodcastEpisodeBody( } } } - -@Composable -private fun PodcastEpisodeCover( - image: String?, - note: Note, - accountViewModel: AccountViewModel, -) { - val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) - val imageModifier = - Modifier - .fillMaxWidth() - .aspectRatio(COVER_ASPECT_RATIO) - .clip(imageShape) - - Box(imageModifier) { - if (image != null) { - MyAsyncImage( - imageUrl = image, - contentDescription = stringRes(R.string.preview_card_image_for, image), - contentScale = ContentScale.Crop, - mainImageModifier = Modifier.fillMaxSize(), - loadedImageModifier = imageModifier, - accountViewModel = accountViewModel, - onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, - onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, - ) - } else { - DefaultImageHeader(note, accountViewModel, imageModifier) - } - } -} - -private const val WAVEFORM_SAMPLES = 96 - -private fun flatWaveform(): WaveformData = - // Even baseline — purely decorative since NIP-F4 doesn't carry waveform data. - WaveformData(List(WAVEFORM_SAMPLES) { 0.4f }) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index beb8bdbcbc..03e880e5dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -21,51 +21,38 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight 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.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader -import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent -private val COVER_ASPECT_RATIO = 1.0f - @OptIn(ExperimentalLayoutApi::class) @Composable fun RenderPodcastMetadata( note: Note, makeItShort: Boolean, - canPreview: Boolean, - backgroundColor: MutableState, + @Suppress("UNUSED_PARAMETER") canPreview: Boolean, + @Suppress("UNUSED_PARAMETER") backgroundColor: MutableState, accountViewModel: AccountViewModel, - nav: INav, + @Suppress("UNUSED_PARAMETER") nav: INav, ) { val noteEvent = note.event as? PodcastMetadataEvent ?: return @@ -75,7 +62,7 @@ fun RenderPodcastMetadata( val websites = remember(noteEvent) { noteEvent.websites() } Column(MaterialTheme.colorScheme.replyModifier) { - PodcastCover(image, note, accountViewModel) + PodcastCoverCard(image, note, accountViewModel) Column( modifier = @@ -124,34 +111,3 @@ fun RenderPodcastMetadata( } } } - -@Composable -private fun PodcastCover( - image: String?, - note: Note, - accountViewModel: AccountViewModel, -) { - val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) - val imageModifier = - Modifier - .fillMaxWidth() - .aspectRatio(COVER_ASPECT_RATIO) - .clip(imageShape) - - Box(imageModifier) { - if (image != null) { - MyAsyncImage( - imageUrl = image, - contentDescription = stringRes(R.string.preview_card_image_for, image), - contentScale = ContentScale.Crop, - mainImageModifier = Modifier.fillMaxSize(), - loadedImageModifier = imageModifier, - accountViewModel = accountViewModel, - onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, - onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, - ) - } else { - DefaultImageHeader(note, accountViewModel, imageModifier) - } - } -} 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 49866f79ba..e30adf0f71 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 @@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class FollowPackFeedNewThreadFeedFilter( val followPackNote: AddressableNote, @@ -58,6 +60,7 @@ class FollowPackFeedNewThreadFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, @@ -141,6 +144,8 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is AudioTrackEvent || noteEvent is MusicTrackEvent || noteEvent is MusicPlaylistEvent || + noteEvent is PodcastEpisodeEvent || + noteEvent is PodcastMetadataEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index f2b7319928..52f726d396 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class GeoHashFeedFilter( val tag: String, @@ -82,7 +84,9 @@ class GeoHashFeedFilter( event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || - event is MusicPlaylistEvent + event is MusicPlaylistEvent || + event is PodcastEpisodeEvent || + event is PodcastMetadataEvent ) && event.isTaggedGeoHash(geohash) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 257dba9cc7..0fa9ec6fc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -38,6 +38,8 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByGeohashKinds = listOf( @@ -52,6 +54,8 @@ val PostsByGeohashKinds = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, WikiNoteEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index daf3a81470..a3f47452e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -41,6 +41,8 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class HashtagFeedFilter( val tag: String, @@ -86,7 +88,9 @@ class HashtagFeedFilter( event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || - event is MusicPlaylistEvent + event is MusicPlaylistEvent || + event is PodcastEpisodeEvent || + event is PodcastMetadataEvent ) && event.isTaggedHash(hashTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index ca2b17014b..4941239783 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -43,6 +43,8 @@ import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByHashtagsKinds = listOf( @@ -64,6 +66,8 @@ val PostsByHashtagKinds2 = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) 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 1e7f0dba26..0499722ff1 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 @@ -51,6 +51,8 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class HomeNewThreadFeedFilter( val account: Account, @@ -61,6 +63,7 @@ class HomeNewThreadFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, ClassifiedsEvent.KIND, @@ -131,6 +134,8 @@ class HomeNewThreadFeedFilter( noteEvent is AudioTrackEvent || noteEvent is MusicTrackEvent || noteEvent is MusicPlaylistEvent || + noteEvent is PodcastEpisodeEvent || + noteEvent is PodcastMetadataEvent || noteEvent is VoiceEvent || noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 18800f93dc..b9a379d746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -78,6 +78,8 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -98,6 +100,8 @@ class NotificationFeedFilter( AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarRSVPEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt index 4abe4c7a9c..ce36765f75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesFeedLoaded.kt @@ -39,8 +39,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +// One shared body for both Episodes (kind 54) and Shows (kind 10154) — NoteCompose's +// renderer dispatch picks the right card per item, the list scaffolding is identical. @Composable -fun PodcastEpisodesFeedLoaded( +internal fun PodcastFeedLoaded( loaded: FeedState.Loaded, listState: LazyListState, accountViewModel: AccountViewModel, @@ -57,8 +59,6 @@ fun PodcastEpisodesFeedLoaded( key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> - // NoteCompose wraps the episode card with the standard reactions row + author chrome - // so zap/reply/etc. work the same as on the home feed. NoteCompose( baseNote = item, modifier = Modifier, @@ -74,3 +74,11 @@ fun PodcastEpisodesFeedLoaded( } } } + +@Composable +fun PodcastEpisodesFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt index 55389e62f6..2827387975 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsFeedLoaded.kt @@ -20,55 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding +// Episodes (kind 54) and Shows (kind 10154) feeds render identically — each note flows +// through NoteCompose's renderer dispatch. Keeping one shared composable means a divider +// or padding tweak ships to both screens at once. @Composable fun PodcastsFeedLoaded( loaded: FeedState.Loaded, listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, -) { - val items by loaded.feed.collectAsStateWithLifecycle() - - LazyColumn( - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - itemsIndexed( - items.list, - key = { _, item -> item.idHex }, - contentType = { _, item -> item.event?.kind ?: -1 }, - ) { _, item -> - NoteCompose( - baseNote = item, - modifier = Modifier, - isBoostedNote = false, - quotesLeft = 1, - accountViewModel = accountViewModel, - nav = nav, - ) - - HorizontalDivider(thickness = DividerThickness) - - Spacer(modifier = Modifier.height(8.dp)) - } - } -} +) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt index 19248cc6b4..efb9bdb167 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastEpisodesFilterAssembler.kt @@ -37,14 +37,11 @@ class PodcastEpisodesQueryState( class PodcastEpisodesFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { - val group = - listOf( - PodcastEpisodesSubAssembler(client, ::allKeys), - ) + private val episodes = PodcastEpisodesSubAssembler(client, ::allKeys) override fun invalidateKeys() = invalidateFilters() - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + override fun invalidateFilters() = episodes.invalidateFilters() - override fun destroy() = group.forEach { it.destroy() } + override fun destroy() = episodes.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt index 84b70f1989..85d6e8c995 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/PodcastsFilterAssembler.kt @@ -37,14 +37,11 @@ class PodcastsQueryState( class PodcastsFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { - val group = - listOf( - PodcastsSubAssembler(client, ::allKeys), - ) + private val podcasts = PodcastsSubAssembler(client, ::allKeys) override fun invalidateKeys() = invalidateFilters() - override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + override fun invalidateFilters() = podcasts.invalidateFilters() - override fun destroy() = group.forEach { it.destroy() } + override fun destroy() = podcasts.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 0feba30fba..4a56858280 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -35,6 +35,13 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND) internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) +// NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is +// its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list — +// they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent). +// With the current resolution the "Following" Podcasts tab returns content only if the user +// also kind:3-follows a podcast key. A future change should plumb a TopFilter.PodcastFavorites +// selector through the topNav state and resolve authors via the user's own 10054 list. + fun filterPodcastEventsByAuthors( relay: NormalizedRelayUrl, kinds: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index 697f3abf19..beb0d05142 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -73,15 +73,14 @@ fun filterPodcastEventsByAllCommunities( ): List { if (communitySet.set.isEmpty()) return emptyList() - return communitySet.set - .mapNotNull { - filterPodcastEventsFromAllCommunities( - relay = it.key, - kinds = kinds, - communities = it.value.communities, - since = since?.get(it.key)?.time ?: defaultSince, - ) - }.flatten() + return communitySet.set.flatMap { + filterPodcastEventsFromAllCommunities( + relay = it.key, + kinds = kinds, + communities = it.value.communities, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } } fun filterPodcastEventsFromCommunity( @@ -131,14 +130,13 @@ fun filterPodcastEventsByCommunity( ): List { if (communitySet.set.isEmpty()) return emptyList() - return communitySet.set - .mapNotNull { - filterPodcastEventsFromCommunity( - relay = it.key, - kinds = kinds, - community = it.value.community, - authors = it.value.authors, - since = since?.get(it.key)?.time ?: defaultSince, - ) - }.flatten() + return communitySet.set.flatMap { + filterPodcastEventsFromCommunity( + relay = it.key, + kinds = kinds, + community = it.value.community, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } } 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 683a74efb4..5d20c01627 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 @@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class UserProfileMutualFeedFilter( val user: User, @@ -86,6 +88,8 @@ class UserProfileMutualFeedFilter( it.event is AudioTrackEvent || it.event is MusicTrackEvent || it.event is MusicPlaylistEvent || + it.event is PodcastEpisodeEvent || + it.event is PodcastMetadataEvent || it.event is AudioHeaderEvent || it.event is PublicMessageEvent || it.event is TorrentEvent 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 d1e5ce0d4d..6b49c59c06 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 @@ -50,6 +50,8 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent class UserProfileNewThreadFeedFilter( val user: User, @@ -93,6 +95,8 @@ class UserProfileNewThreadFeedFilter( it.event is AudioTrackEvent || it.event is MusicTrackEvent || it.event is MusicPlaylistEvent || + it.event is PodcastEpisodeEvent || + it.event is PodcastMetadataEvent || it.event is AudioHeaderEvent || it.event is VoiceEvent || it.event is TorrentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt index 69fb17f108..9174f45a76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -40,6 +40,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent val PostsByRelayKinds = listOf( @@ -61,6 +63,8 @@ val PostsByRelayKinds2 = AudioHeaderEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, + PodcastEpisodeEvent.KIND, + PodcastMetadataEvent.KIND, NipTextEvent.KIND, ZapPollEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 310df52ed6..4e2c151cd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -261,6 +261,10 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -540,6 +544,10 @@ fun kindDisplayName(kind: Int): Int = AudioTrackEvent.KIND -> R.string.kind_audio_track MusicTrackEvent.KIND -> R.string.kind_music_track MusicPlaylistEvent.KIND -> R.string.kind_music_playlist + PodcastEpisodeEvent.KIND -> R.string.kind_podcast_episode + PodcastMetadataEvent.KIND -> R.string.kind_podcast_metadata + AuthoredPodcastsEvent.KIND -> R.string.kind_authored_podcasts + FavoritePodcastsListEvent.KIND -> R.string.kind_favorite_podcasts AttestationEvent.KIND -> R.string.attestation AttestationRequestEvent.KIND -> R.string.attestation_request AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation 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 a5c7ecc0b5..244bf222ff 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 @@ -173,6 +173,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode +import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage @@ -308,6 +310,8 @@ import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -646,6 +650,10 @@ private fun FullBleedNoteCompose( RenderMusicTrack(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is MusicPlaylistEvent) { RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PodcastEpisodeEvent) { + RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is PodcastMetadataEvent) { + RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c06d3b6217..ef898c3cc7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2598,6 +2598,10 @@ Audio Track Music Track Music Playlist + Podcast Episode + Podcast Show + Authored Podcasts + Favorite Podcasts Badge Awards Badge Definitions Accepted Badge Set diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt index 409e1ba484..e492348c9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/favorites/FavoritePodcastsListEvent.kt @@ -86,19 +86,33 @@ class FavoritePodcastsListEvent( val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + // MUST strip from public too — a podcast that was previously public and is now + // being made private must not stay visible in the unencrypted tag list. resign( - tags = earlierVersion.tags, + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()), privateTags = privateTags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), signer = signer, createdAt = createdAt, ) } else { - resign( - content = earlierVersion.content, - tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), - signer = signer, - createdAt = createdAt, - ) + // Symmetric: strip from private tags too when moving a previously-private + // podcast to the public list, otherwise it sits in both halves. + val privateTags = earlierVersion.privateTags(signer) + if (privateTags != null) { + resign( + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + privateTags = privateTags.remove(podcast.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } } suspend fun remove( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt index 23deb816ce..b8a05e88e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/tags/AuthorTag.kt @@ -49,13 +49,18 @@ class AuthorTag( const val ROLE_COHOST = "cohost" const val ROLE_EDITOR = "editor" + private val KNOWN_ROLES = setOf(ROLE_HOST, ROLE_COHOST, ROLE_EDITOR) + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 fun parse(tag: Array): AuthorTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length == 64) { return null } - val role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + // NIP-F4 overloads slot 2 as role — but only host/cohost/editor are spec-defined. + // Anything else (notably a stale relay-hint URL copied from a kind:3 p-tag) gets + // dropped so it doesn't render as 'Role: wss://relay…' under the author chip. + val role = tag.getOrNull(2)?.takeIf { it in KNOWN_ROLES } return AuthorTag(tag[1], role) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt index cf177d88ae..98a3570f3e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/FavoritePodcastsListEventTest.kt @@ -121,6 +121,60 @@ class FavoritePodcastsListEventTest { ) } + @Test + fun `moving public favorite to private strips the public p-tag`() = + runTest { + val publicFirst = + FavoritePodcastsListEvent.create( + publicFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val nowPrivate = + FavoritePodcastsListEvent.add( + earlierVersion = publicFirst, + podcast = UserTag(podcast1), + isPrivate = true, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertFalse( + nowPrivate.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Moving a public favorite to private must strip it from the unencrypted tag list", + ) + val priv = assertNotNull(nowPrivate.privateFavorites(signer)) + assertEquals(listOf(podcast1), priv.map { it.pubKey }) + } + + @Test + fun `moving private favorite to public strips the encrypted entry`() = + runTest { + val privateFirst = + FavoritePodcastsListEvent.create( + privateFavorites = listOf(UserTag(podcast1)), + signer = signer, + createdAt = 1_700_000_000, + ) + + val nowPublic = + FavoritePodcastsListEvent.add( + earlierVersion = privateFirst, + podcast = UserTag(podcast1), + isPrivate = false, + signer = signer, + createdAt = 1_700_000_001, + ) + + assertTrue( + nowPublic.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 }, + "Promoted entry must be present as a public p-tag", + ) + val priv = assertNotNull(nowPublic.privateFavorites(signer)) + assertTrue(priv.isEmpty(), "Promoted entry must no longer be in the encrypted list") + } + @Test fun `remove drops the matching pubkey from public tags`() = runTest {