fix(podcasts): wire podcast kinds into every consumer; close audit findings

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.
This commit is contained in:
Claude
2026-05-28 22:14:49 +00:00
parent df0bb2641a
commit ab0719fa68
27 changed files with 311 additions and 221 deletions
@@ -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)
}
@@ -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,
@@ -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)
}
}
}
@@ -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<Float>+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<Color>,
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 })
@@ -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<Color>,
@Suppress("UNUSED_PARAMETER") canPreview: Boolean,
@Suppress("UNUSED_PARAMETER") backgroundColor: MutableState<Color>,
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)
}
}
}
@@ -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
) &&
@@ -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)
@@ -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,
)
@@ -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)
@@ -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,
)
@@ -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 ||
@@ -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,
@@ -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)
@@ -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)
@@ -37,14 +37,11 @@ class PodcastEpisodesQueryState(
class PodcastEpisodesFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<PodcastEpisodesQueryState>() {
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()
}
@@ -37,14 +37,11 @@ class PodcastsQueryState(
class PodcastsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<PodcastsQueryState>() {
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()
}
@@ -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<Int>,
@@ -73,15 +73,14 @@ fun filterPodcastEventsByAllCommunities(
): List<RelayBasedFilter> {
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<RelayBasedFilter> {
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,
)
}
}
@@ -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
@@ -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 ||
@@ -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,
)
@@ -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
@@ -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,
+4
View File
@@ -2598,6 +2598,10 @@
<string name="kind_audio_track">Audio Track</string>
<string name="kind_music_track">Music Track</string>
<string name="kind_music_playlist">Music Playlist</string>
<string name="kind_podcast_episode">Podcast Episode</string>
<string name="kind_podcast_metadata">Podcast Show</string>
<string name="kind_authored_podcasts">Authored Podcasts</string>
<string name="kind_favorite_podcasts">Favorite Podcasts</string>
<string name="kind_badge_awards">Badge Awards</string>
<string name="kind_badge_definitions">Badge Definitions</string>
<string name="kind_accepted_badge_set">Accepted Badge Set</string>
@@ -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(
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun parse(tag: Array<String>): 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)
}
@@ -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 {