mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
) &&
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
+5
@@ -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 ||
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+11
-3
@@ -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)
|
||||
|
||||
+4
-40
@@ -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)
|
||||
|
||||
+3
-6
@@ -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()
|
||||
}
|
||||
|
||||
+3
-6
@@ -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()
|
||||
}
|
||||
|
||||
+7
@@ -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>,
|
||||
|
||||
+17
-19
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+4
@@ -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 ||
|
||||
|
||||
+4
@@ -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,
|
||||
)
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+21
-7
@@ -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(
|
||||
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+54
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user