Merge pull request #3093 from vitorpamplona/claude/adoring-turing-THM7p

Add podcast support (NIP-F4) with favorites, metadata, and episodes
This commit is contained in:
Vitor Pamplona
2026-05-28 18:24:11 -04:00
committed by GitHub
73 changed files with 3733 additions and 2 deletions
@@ -114,6 +114,8 @@ private object PrefKeys {
const val DEFAULT_ARTICLES_FOLLOW_LIST = "defaultArticlesFollowList"
const val DEFAULT_MUSIC_TRACKS_FOLLOW_LIST = "defaultMusicTracksFollowList"
const val DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST = "defaultMusicPlaylistsFollowList"
const val DEFAULT_PODCAST_EPISODES_FOLLOW_LIST = "defaultPodcastEpisodesFollowList"
const val DEFAULT_PODCASTS_FOLLOW_LIST = "defaultPodcastsFollowList"
const val DEFAULT_SOFTWARE_APPS_FOLLOW_LIST = "defaultSoftwareAppsFollowList"
const val DEFAULT_BADGES_FOLLOW_LIST = "defaultBadgesFollowList"
const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList"
@@ -383,6 +385,8 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value))
putString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicTracksFollowList.value))
putString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicPlaylistsFollowList.value))
putString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastEpisodesFollowList.value))
putString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastsFollowList.value))
putString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultSoftwareAppsFollowList.value))
putString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value))
putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value))
@@ -667,6 +671,8 @@ object LocalPreferences {
defaultLongsFollowList = MutableStateFlow(followListPrefs.longs),
defaultMusicTracksFollowList = MutableStateFlow(followListPrefs.musicTracks),
defaultMusicPlaylistsFollowList = MutableStateFlow(followListPrefs.musicPlaylists),
defaultPodcastEpisodesFollowList = MutableStateFlow(followListPrefs.podcastEpisodes),
defaultPodcastsFollowList = MutableStateFlow(followListPrefs.podcasts),
defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles),
defaultSoftwareAppsFollowList = MutableStateFlow(followListPrefs.softwareApps),
defaultBadgesFollowList = MutableStateFlow(followListPrefs.badges),
@@ -743,6 +749,8 @@ object LocalPreferences {
val articles: TopFilter,
val musicTracks: TopFilter,
val musicPlaylists: TopFilter,
val podcastEpisodes: TopFilter,
val podcasts: TopFilter,
val softwareApps: TopFilter,
val badges: TopFilter,
val browseEmojiSets: TopFilter,
@@ -768,6 +776,8 @@ object LocalPreferences {
articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows),
musicTracks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, null), TopFilter.Global),
musicPlaylists = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, null), TopFilter.Global),
podcastEpisodes = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, null), TopFilter.Global),
podcasts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, null), TopFilter.Global),
softwareApps = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, null), TopFilter.Global),
badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine),
browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global),
@@ -520,6 +520,12 @@ class Account(
val liveMusicPlaylistsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultMusicPlaylistsFollowList)
val liveMusicPlaylistsFollowListsPerRelay = OutboxLoaderState(liveMusicPlaylistsFollowLists, cache, scope).flow
val livePodcastEpisodesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPodcastEpisodesFollowList)
val livePodcastEpisodesFollowListsPerRelay = OutboxLoaderState(livePodcastEpisodesFollowLists, cache, scope).flow
val livePodcastsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPodcastsFollowList)
val livePodcastsFollowListsPerRelay = OutboxLoaderState(livePodcastsFollowLists, cache, scope).flow
val liveSoftwareAppsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultSoftwareAppsFollowList)
val liveSoftwareAppsFollowListsPerRelay = OutboxLoaderState(liveSoftwareAppsFollowLists, cache, scope).flow
@@ -173,6 +173,8 @@ class AccountSettings(
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultMusicTracksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultMusicPlaylistsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPodcastEpisodesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPodcastsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultSoftwareAppsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -652,6 +654,28 @@ class AccountSettings(
}
}
fun changeDefaultPodcastEpisodesFollowList(name: FeedDefinition) {
changeDefaultPodcastEpisodesFollowList(name.code)
}
fun changeDefaultPodcastEpisodesFollowList(name: TopFilter) {
if (defaultPodcastEpisodesFollowList.value != name) {
defaultPodcastEpisodesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultPodcastsFollowList(name: FeedDefinition) {
changeDefaultPodcastsFollowList(name.code)
}
fun changeDefaultPodcastsFollowList(name: TopFilter) {
if (defaultPodcastsFollowList.value != name) {
defaultPodcastsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) {
changeDefaultSoftwareAppsFollowList(name.code)
}
@@ -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)
}
@@ -54,6 +54,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
@@ -127,6 +129,8 @@ class RelaySubscriptionsCoordinator(
val articles = ArticlesFilterAssembler(client)
val musicTracks = MusicTracksFilterAssembler(client)
val musicPlaylists = MusicPlaylistsFilterAssembler(client)
val podcastEpisodes = PodcastEpisodesFilterAssembler(client)
val podcasts = PodcastsFilterAssembler(client)
val softwareApps = SoftwareAppsFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
@@ -162,6 +166,8 @@ class RelaySubscriptionsCoordinator(
articles,
musicTracks,
musicPlaylists,
podcastEpisodes,
podcasts,
softwareApps,
badges,
profileBadges,
@@ -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,
@@ -77,6 +77,8 @@ object ScrollStateKeys {
const val ARTICLES_SCREEN = "ArticlesFeed"
const val MUSIC_TRACKS_SCREEN = "MusicTracksFeed"
const val MUSIC_PLAYLISTS_SCREEN = "MusicPlaylistsFeed"
const val PODCAST_EPISODES_SCREEN = "PodcastEpisodesFeed"
const val PODCASTS_SCREEN = "PodcastsFeed"
const val SEARCH_SCREEN = "SearchFeed"
@@ -147,6 +147,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -289,6 +291,8 @@ fun BuildNavigation(
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
composableFromEnd<Route.MusicTracks> { MusicTracksScreen(accountViewModel, nav) }
composableFromEnd<Route.MusicPlaylists> { MusicPlaylistsScreen(accountViewModel, nav) }
composableFromEnd<Route.PodcastEpisodes> { PodcastEpisodesScreen(accountViewModel, nav) }
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
@@ -56,6 +56,8 @@ enum class NavBarItem {
SHORTS,
MUSIC_TRACKS,
MUSIC_PLAYLISTS,
PODCAST_EPISODES,
PODCASTS,
PUBLIC_CHATS,
FOLLOW_PACKS,
LIVE_STREAMS,
@@ -246,6 +248,20 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.AutoMirrored.PlaylistAdd,
resolveRoute = { Route.MusicPlaylists },
),
NavBarItem.PODCAST_EPISODES to
NavBarItemDef(
id = NavBarItem.PODCAST_EPISODES,
labelRes = R.string.route_podcast_episodes,
icon = MaterialSymbols.Headphones,
resolveRoute = { Route.PodcastEpisodes },
),
NavBarItem.PODCASTS to
NavBarItemDef(
id = NavBarItem.PODCASTS,
labelRes = R.string.route_podcasts,
icon = MaterialSymbols.Podcasts,
resolveRoute = { Route.Podcasts },
),
NavBarItem.PUBLIC_CHATS to
NavBarItemDef(
id = NavBarItem.PUBLIC_CHATS,
@@ -364,6 +380,8 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.SHORTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
NavBarItem.LIVE_STREAMS,
@@ -158,6 +158,10 @@ sealed class Route {
@Serializable object MusicPlaylists : Route()
@Serializable object PodcastEpisodes : Route()
@Serializable object Podcasts : Route()
@Serializable
data class NewMusicTrack(
val dTag: String? = null,
@@ -156,6 +156,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent
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
@@ -315,6 +317,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.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -930,6 +934,14 @@ private fun RenderNoteRow(
RenderMusicPlaylist(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is PodcastEpisodeEvent -> {
RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is PodcastMetadataEvent -> {
RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav)
}
is DraftWrapEvent -> {
RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
}
@@ -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)
}
}
}
@@ -0,0 +1,174 @@
/*
* 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
// 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(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? PodcastEpisodeEvent ?: return
val title = remember(noteEvent) { noteEvent.title() }
val image = remember(noteEvent) { noteEvent.image() }
val description = remember(noteEvent) { noteEvent.description() }
// 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 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) {
PodcastCoverCard(image, note, accountViewModel)
firstAudio?.let { audio ->
val callbackUri = remember(noteEvent) { note.toNostrUri() }
Row(
Modifier.fillMaxWidth().height(80.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GetMediaItem(
videoUri = audio.url,
title = title,
artworkUri = image,
authorName = note.author?.toBestDisplayName(),
callbackUri = callbackUri,
mimeType = audio.mediaType,
aspectRatio = null,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url),
keepPlaying = false,
waveformData = FLAT_WAVEFORM,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,
waveform = FLAT_WAVEFORM,
borderModifier = PLAYER_BORDER_MODIFIER,
accountViewModel = accountViewModel,
)
}
}
}
}
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
markdown?.takeIf { !makeItShort }?.let {
Spacer(Modifier.padding(top = 4.dp))
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
val callbackUri = remember(noteEvent) { note.toNostrUri() }
TranslatableRichTextViewer(
content = it,
canPreview = canPreview,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
id = note.idHex,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@@ -0,0 +1,113 @@
/*
* 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.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderPodcastMetadata(
note: Note,
makeItShort: Boolean,
@Suppress("UNUSED_PARAMETER") canPreview: Boolean,
@Suppress("UNUSED_PARAMETER") backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
@Suppress("UNUSED_PARAMETER") nav: INav,
) {
val noteEvent = note.event as? PodcastMetadataEvent ?: return
val title = remember(noteEvent) { noteEvent.title() }
val image = remember(noteEvent) { noteEvent.image() }
val description = remember(noteEvent) { noteEvent.description() }
val websites = remember(noteEvent) { noteEvent.websites() }
Column(MaterialTheme.colorScheme.replyModifier) {
PodcastCoverCard(image, note, accountViewModel)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
description?.takeIf { !makeItShort }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
if (websites.isNotEmpty() && !makeItShort) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Size5dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
) {
websites.forEach { website ->
Text(
text = website,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
@@ -59,6 +59,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal.PictureFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastEpisodesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.PodcastsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.ClosedPollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.OpenPollsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter
@@ -116,6 +118,8 @@ class AccountFeedContentStates(
val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache)
val musicTracksFeed = FeedContentState(MusicTracksFeedFilter(account), scope, LocalCache)
val musicPlaylistsFeed = FeedContentState(MusicPlaylistsFeedFilter(account), scope, LocalCache)
val podcastEpisodesFeed = FeedContentState(PodcastEpisodesFeedFilter(account), scope, LocalCache)
val podcastsFeed = FeedContentState(PodcastsFeedFilter(account), scope, LocalCache)
val softwareAppsFeed = FeedContentState(SoftwareAppsFeedFilter(account), scope, LocalCache)
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
@@ -207,6 +211,8 @@ class AccountFeedContentStates(
articlesFeed.updateFeedWith(newNotes)
musicTracksFeed.updateFeedWith(newNotes)
musicPlaylistsFeed.updateFeedWith(newNotes)
podcastEpisodesFeed.updateFeedWith(newNotes)
podcastsFeed.updateFeedWith(newNotes)
softwareAppsFeed.updateFeedWith(newNotes)
calendarAppointmentsFeed.updateFeedWith(newNotes)
@@ -265,6 +271,8 @@ class AccountFeedContentStates(
articlesFeed.deleteFromFeed(newNotes)
musicTracksFeed.deleteFromFeed(newNotes)
musicPlaylistsFeed.deleteFromFeed(newNotes)
podcastEpisodesFeed.deleteFromFeed(newNotes)
podcastsFeed.deleteFromFeed(newNotes)
softwareAppsFeed.deleteFromFeed(newNotes)
calendarAppointmentsFeed.deleteFromFeed(newNotes)
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilte
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssemblerSubscription
@@ -102,6 +104,10 @@ private fun PreloadFor(
// tabs no matter which one the user pinned.
-> MusicTracksFilterAssemblerSubscription(accountViewModel)
NavBarItem.PODCAST_EPISODES -> PodcastEpisodesFilterAssemblerSubscription(accountViewModel)
NavBarItem.PODCASTS -> PodcastsFilterAssemblerSubscription(accountViewModel)
NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
@@ -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,
@@ -0,0 +1,84 @@
/*
* 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.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
// 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
internal fun PodcastFeedLoaded(
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))
}
}
}
@Composable
fun PodcastEpisodesFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav)
@@ -0,0 +1,135 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
fun PodcastEpisodesScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
PodcastEpisodesScreen(
episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun PodcastEpisodesScreen(
episodesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(episodesFeedContentState)
WatchAccountForPodcastEpisodesScreen(
episodesFeedState = episodesFeedContentState,
accountViewModel = accountViewModel,
)
// Drives the kind:54 REQ for the user's selected follow list — without it the screen
// only shows episodes already in cache from some other path (e.g. a nostr: link).
PodcastEpisodesFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
PodcastEpisodesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.PodcastEpisodes, nav, accountViewModel) { route ->
if (route == Route.PodcastEpisodes) {
episodesFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(episodesFeedContentState, true) {
SaveableFeedContentState(episodesFeedContentState, scrollStateKey = ScrollStateKeys.PODCAST_EPISODES_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = episodesFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "PodcastEpisodesFeed",
onLoaded = { loaded ->
PodcastEpisodesFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
@Composable
fun WatchAccountForPodcastEpisodesScreen(
episodesFeedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.livePodcastEpisodesFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
episodesFeedState.checkKeysInvalidateDataAndSendToTop()
}
}
@Preview
@Composable
private fun PodcastEpisodesScreenPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
PodcastEpisodesScreen(
episodesFeedContentState = accountViewModel.feedStates.podcastEpisodesFeed,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@@ -0,0 +1,84 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
fun PodcastEpisodesTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultPodcastEpisodesFollowList
.collectAsStateWithLifecycle()
PodcastEpisodesTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultPodcastEpisodesFollowList,
)
}
}
@Composable
private fun PodcastEpisodesTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
// Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3
// Follows, Around Me, Global, people lists, interest sets, mute list.
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@Preview
@Composable
private fun PodcastEpisodesTopBarPreview() {
ThemeComparisonColumn {
PodcastEpisodesTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav())
}
}
@@ -0,0 +1,38 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
// 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,
) = PodcastFeedLoaded(loaded, listState, accountViewModel, nav)
@@ -0,0 +1,135 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
fun PodcastsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
PodcastsScreen(
podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun PodcastsScreen(
podcastsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(podcastsFeedContentState)
WatchAccountForPodcastsScreen(
podcastsFeedState = podcastsFeedContentState,
accountViewModel = accountViewModel,
)
// Dedicated REQ for kind 10154 only — episode events come down through the episodes
// screen's own subscription, keeping each screen's `since` cursor isolated.
PodcastsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
PodcastsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Podcasts, nav, accountViewModel) { route ->
if (route == Route.Podcasts) {
podcastsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(podcastsFeedContentState, true) {
SaveableFeedContentState(podcastsFeedContentState, scrollStateKey = ScrollStateKeys.PODCASTS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = podcastsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "PodcastsFeed",
onLoaded = { loaded ->
PodcastsFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
@Composable
fun WatchAccountForPodcastsScreen(
podcastsFeedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.livePodcastsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
podcastsFeedState.checkKeysInvalidateDataAndSendToTop()
}
}
@Preview
@Composable
private fun PodcastsScreenPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
PodcastsScreen(
podcastsFeedContentState = accountViewModel.feedStates.podcastsFeed,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@@ -0,0 +1,82 @@
/*
* 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.screen.loggedIn.podcasts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
fun PodcastsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultPodcastsFollowList
.collectAsStateWithLifecycle()
PodcastsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultPodcastsFollowList,
)
}
}
@Composable
private fun PodcastsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@Preview
@Composable
private fun PodcastsTopBarPreview() {
ThemeComparisonColumn {
PodcastsTopBar(accountViewModel = mockAccountViewModel(), nav = EmptyNav())
}
}
@@ -0,0 +1,88 @@
/*
* 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.screen.loggedIn.podcasts.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
/**
* Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike
* music tracks/playlists which are addressable. Same follow-list + hidden/blocked
* gate as MusicTracksFeedFilter.
*/
class PodcastEpisodesFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "podcast-episodes-" + account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultPodcastEpisodesFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
accept(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.livePodcastEpisodesFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) { accept(it, params) }
}
private fun accept(
note: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = note.event
return noteEvent is PodcastEpisodeEvent &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,87 @@
/*
* 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.screen.loggedIn.podcasts.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
/**
* Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in
* `LocalCache.addressables` — same shape as the music-playlists filter.
*/
class PodcastsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "podcasts-" + account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultPodcastsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it ->
accept(it, params)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.livePodcastsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) { accept(it, params) }
}
private fun accept(
note: Note,
params: FilterByListParams,
): Boolean {
val noteEvent = note.event
return noteEvent is PodcastMetadataEvent &&
params.match(noteEvent, note.relays) &&
(params.isHiddenList || account.isAcceptable(note))
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,47 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PodcastEpisodesQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class PodcastEpisodesFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<PodcastEpisodesQueryState>() {
private val episodes = PodcastEpisodesSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = episodes.invalidateFilters()
override fun destroy() = episodes.destroy()
}
@@ -0,0 +1,48 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun PodcastEpisodesFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
PodcastEpisodesFilterAssemblerSubscription(
accountViewModel.dataSources().podcastEpisodes,
accountViewModel,
)
}
@Composable
fun PodcastEpisodesFilterAssemblerSubscription(
dataSource: PodcastEpisodesFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
PodcastEpisodesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,96 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class PodcastEpisodesSubAssembler(
client: INostrClient,
allKeys: () -> Set<PodcastEpisodesQueryState>,
) : PerUserAndFollowListEoseManager<PodcastEpisodesQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: PodcastEpisodesQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makePodcastEpisodesFilter(feedSettings, since, key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: PodcastEpisodesQueryState) = key.account.userProfile()
override fun list(key: PodcastEpisodesQueryState) = key.listName()
fun PodcastEpisodesQueryState.listNameFlow() = account.settings.defaultPodcastEpisodesFollowList
fun PodcastEpisodesQueryState.listName() = listNameFlow().value
fun PodcastEpisodesQueryState.followsPerRelayFlow() = account.livePodcastEpisodesFollowListsPerRelay
fun PodcastEpisodesQueryState.followsPerRelay() = followsPerRelayFlow().value
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: PodcastEpisodesQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.podcastEpisodesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,47 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class PodcastsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class PodcastsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<PodcastsQueryState>() {
private val podcasts = PodcastsSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = podcasts.invalidateFilters()
override fun destroy() = podcasts.destroy()
}
@@ -0,0 +1,48 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun PodcastsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
PodcastsFilterAssemblerSubscription(
accountViewModel.dataSources().podcasts,
accountViewModel,
)
}
@Composable
fun PodcastsFilterAssemblerSubscription(
dataSource: PodcastsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
PodcastsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,96 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class PodcastsSubAssembler(
client: INostrClient,
allKeys: () -> Set<PodcastsQueryState>,
) : PerUserAndFollowListEoseManager<PodcastsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: PodcastsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makePodcastsFilter(feedSettings, since, key.feedStates.podcastsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: PodcastsQueryState) = key.account.userProfile()
override fun list(key: PodcastsQueryState) = key.listName()
fun PodcastsQueryState.listNameFlow() = account.settings.defaultPodcastsFollowList
fun PodcastsQueryState.listName() = listNameFlow().value
fun PodcastsQueryState.followsPerRelayFlow() = account.livePodcastsFollowListsPerRelay
fun PodcastsQueryState.followsPerRelay() = followsPerRelayFlow().value
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: PodcastsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.podcastsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,73 @@
/*
* 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.screen.loggedIn.podcasts.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makePodcastEpisodesFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> = makePodcastFilter(feedSettings, PODCAST_EPISODE_KINDS, since, defaultSince)
fun makePodcastsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince)
private fun makePodcastFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,110 @@
/*
* 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.screen.loggedIn.podcasts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
// Each podcast screen issues its own REQ keyed on its own follow-list selector — keep
// the kind lists split so episodes and show metadata never co-mingle in one filter.
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>,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = kinds,
limit = 200,
since = since,
),
),
)
}
fun filterPodcastEventsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterPodcastEventsByAuthors(
relay = it.key,
kinds = kinds,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterPodcastEventsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterPodcastEventsByAuthors(
relay = it.key,
kinds = kinds,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,142 @@
/*
* 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.screen.loggedIn.podcasts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
fun filterPodcastEventsFromAllCommunities(
relay: NormalizedRelayUrl,
kinds: List<Int>,
communities: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> {
val communityList = communities.sorted()
val kindsAsStrings = kinds.map { it.toString() }
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = CommunityPostApprovalEvent.KIND_LIST,
tags =
mapOf(
"a" to communityList,
"k" to kindsAsStrings,
),
limit = communityList.size * 20,
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
tags = mapOf("a" to communityList),
limit = communityList.size * 20,
since = since,
),
),
)
}
fun filterPodcastEventsByAllCommunities(
communitySet: AllCommunitiesTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set.flatMap {
filterPodcastEventsFromAllCommunities(
relay = it.key,
kinds = kinds,
communities = it.value.communities,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
fun filterPodcastEventsFromCommunity(
relay: NormalizedRelayUrl,
kinds: List<Int>,
community: String,
authors: Set<String>?,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors?.sorted()
val kindsAsStrings = kinds.map { it.toString() }
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = CommunityPostApprovalEvent.KIND_LIST,
tags =
mapOf(
"a" to listOf(community),
"k" to kindsAsStrings,
),
limit = 100,
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = kinds,
tags = mapOf("a" to listOf(community)),
limit = 100,
since = since,
),
),
)
}
fun filterPodcastEventsByCommunity(
communitySet: SingleCommunityTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
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,
)
}
}
@@ -0,0 +1,45 @@
/*
* 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.screen.loggedIn.podcasts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterPodcastEventsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val sinceForRelay = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let { authors ->
filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay)
},
).flatten()
}
}
@@ -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.screen.loggedIn.podcasts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterPodcastEventsByGeohashes(
relay: NormalizedRelayUrl,
kinds: List<Int>,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
tags = mapOf("g" to geotags.sorted()),
limit = 100,
since = since,
),
),
)
}
fun filterPodcastEventsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterPodcastEventsByGeohashes(
relay = it.key,
kinds = kinds,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,69 @@
/*
* 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.screen.loggedIn.podcasts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterPodcastEventsByHashtag(
relay: NormalizedRelayUrl,
kinds: List<Int>,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
tags = mapOf("t" to hashtags.sorted()),
limit = 200,
since = since,
),
),
)
fun filterPodcastEventsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull {
if (it.value.hashtags.isEmpty()) {
null
} else {
filterPodcastEventsByHashtag(
relay = it.key,
kinds = kinds,
hashtags = it.value.hashtags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,50 @@
/*
* 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.screen.loggedIn.podcasts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
fun filterPodcastEventsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
kinds: List<Int>,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
// Podcasts are sparse on most relays today, so no `oneWeekAgo` floor — let the relay's
// own limit + kind filter bound the response. Mirrors the music-global rationale.
val sinceForRelay = since?.get(it.key)?.time ?: defaultSince
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = kinds,
limit = 200,
since = sinceForRelay,
),
)
}
}
@@ -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,
+6
View File
@@ -764,6 +764,8 @@
<string name="articles">Articles</string>
<string name="route_music_tracks">Music</string>
<string name="route_music_playlists">Playlists</string>
<string name="route_podcast_episodes">Episodes</string>
<string name="route_podcasts">Podcasts</string>
<string name="new_music_track">New music track</string>
<string name="new_music_playlist">New playlist</string>
<string name="music_track_title_label">Title</string>
@@ -2596,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>
@@ -121,6 +121,7 @@ object MaterialSymbols {
val Group = MaterialSymbol("\uEA21")
val GroupAdd = MaterialSymbol("\uE7F0")
val Groups = MaterialSymbol("\uF233")
val Headphones = MaterialSymbol("\uF01F")
val Hearing = MaterialSymbol("\uE023")
val History = MaterialSymbol("\uE8B3")
val Home = MaterialSymbol("\uE9B2")
@@ -171,6 +172,7 @@ object MaterialSymbols {
val PlayArrow = MaterialSymbol("\uE037")
val PlayCircle = MaterialSymbol("\uE1C4")
val PlayCircleOutline = MaterialSymbol("\uE1C4")
val Podcasts = MaterialSymbol("\uF048")
val Poll = MaterialSymbol("\uF0CC")
val PrivacyTip = MaterialSymbol("\uF0DC")
val Public = MaterialSymbol("\uE80B")
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.authored
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-F4 author counter-claim (`kind:10064`). A user authors this to assert which
* podcast pubkeys they actually create — used by clients to confirm that the
* authorship claim made by a [com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent]
* is reciprocated by the alleged author, and to discover all podcasts authored
* by a given user.
*/
@Immutable
class AuthoredPodcastsEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PubKeyHintProvider {
fun authored() = tags.mapNotNull(UserTag::parse)
fun authoredKeys() = tags.mapNotNull(UserTag::parseKey)
fun authors(podcastPubKey: HexKey) = tags.any { UserTag.isTagged(it, podcastPubKey) }
override fun linkedPubKeys() = tags.mapNotNull(UserTag::parseKey)
override fun pubKeyHints() = tags.mapNotNull(UserTag::parseAsHint)
companion object {
const val KIND = 10064
const val ALT_DESCRIPTION = "List of authored podcasts"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun build(
podcasts: List<UserTag>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AuthoredPodcastsEvent>.() -> Unit = {},
) = eventTemplate<AuthoredPodcastsEvent>(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
podcasts.forEach { podcast(it) }
initializer()
}
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.authored
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
fun TagArrayBuilder<AuthoredPodcastsEvent>.podcast(podcast: UserTag) = add(podcast.toTagArray())
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.episode
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-F4 podcast episode (`kind:54`), regular event authored by the podcast pubkey.
*
* `content` is the markdown-formatted episode notes. Title, image, short description,
* and one or more audio URLs live in tags so clients can render listings without
* parsing markdown.
*/
@Immutable
class PodcastEpisodeEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun audios() = tags.mapNotNull(AudioTag::parse)
companion object {
const val KIND = 54
const val ALT_DESCRIPTION_PREFIX = "Podcast episode"
fun build(
title: String,
description: String,
audios: List<AudioTag>,
markdownContent: String = "",
image: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PodcastEpisodeEvent>.() -> Unit = {},
) = eventTemplate<PodcastEpisodeEvent>(KIND, markdownContent, createdAt) {
alt("$ALT_DESCRIPTION_PREFIX: $title")
title(title)
description(description)
image?.let { image(it) }
audios.forEach { audio(it) }
initializer()
}
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.episode
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag
fun TagArrayBuilder<PodcastEpisodeEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<PodcastEpisodeEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<PodcastEpisodeEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<PodcastEpisodeEvent>.audio(audio: AudioTag) = add(AudioTag.assemble(audio))
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-F4 episode audio tag: `["audio", "<url>", "<optional_media_type>"]`.
*
* Multiple `audio` tags are allowed — typically used to provide the same episode
* in different containers/codecs (e.g. mp3 + opus). When present, [mediaType] is
* a MIME type the client can use to pick a stream it can decode without HEAD-ing
* the URL first.
*/
@Stable
class AudioTag(
val url: String,
val mediaType: String? = null,
) {
fun toTagArray() = assemble(url, mediaType)
companion object {
const val TAG_NAME = "audio"
fun parse(tag: Array<String>): AudioTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }
return AudioTag(tag[1], mediaType)
}
fun assemble(
url: String,
mediaType: String? = null,
): Array<String> =
if (mediaType.isNullOrEmpty()) {
arrayOf(TAG_NAME, url)
} else {
arrayOf(TAG_NAME, url, mediaType)
}
fun assemble(audio: AudioTag) = assemble(audio.url, audio.mediaType)
fun assemble(audios: List<AudioTag>) = audios.map { assemble(it) }
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class DescriptionTag {
companion object {
const val TAG_NAME = "description"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(description: String) = arrayOf(TAG_NAME, description)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ImageTag {
companion object {
const val TAG_NAME = "image"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.episode.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class TitleTag {
companion object {
const val TAG_NAME = "title"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(title: String) = arrayOf(TAG_NAME, title)
}
}
@@ -0,0 +1,170 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.favorites
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-F4 user favorite-podcasts list (`kind:10054`). Per the spec this advertises
* the podcasts the user listens to as a soft public recommendation. Items are
* `p` tags pointing at podcast pubkeys.
*
* Follows the NIP-51 convention of also allowing private items stored encrypted
* in `content`, so a user can keep guilty pleasures off the public list while
* still using the same event kind for their own subscription state.
*/
@Immutable
class FavoritePodcastsListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicFavorites() = tags.mapNotNull(UserTag::parse)
suspend fun privateFavorites(signer: NostrSigner) = privateTags(signer)?.mapNotNull(UserTag::parse)
companion object {
const val KIND = 10054
const val ALT = "Favorite podcasts list"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "")
suspend fun create(
publicFavorites: List<UserTag> = emptyList(),
privateFavorites: List<UserTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent =
resign(
content = PrivateTagsInContent.encryptNip44(privateFavorites.map { it.toTagArray() }.toTypedArray(), signer),
tags = publicFavorites.map { it.toTagArray() }.toTypedArray(),
signer = signer,
createdAt = createdAt,
)
suspend fun add(
earlierVersion: FavoritePodcastsListEvent,
podcast: UserTag,
isPrivate: Boolean,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent =
if (isPrivate) {
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.remove(podcast.toTagIdOnly()),
privateTags = privateTags.remove(podcast.toTagIdOnly()) + podcast.toTagArray(),
signer = signer,
createdAt = createdAt,
)
} else {
// 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(
earlierVersion: FavoritePodcastsListEvent,
podcast: UserTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent {
val idOnly = podcast.toTagIdOnly()
val privateTags = earlierVersion.privateTags(signer)
return if (privateTags != null) {
resign(
privateTags = privateTags.remove(idOnly),
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
} else {
resign(
content = earlierVersion.content,
tags = earlierVersion.tags.remove(idOnly),
signer = signer,
createdAt = createdAt,
)
}
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = resign(
content = PrivateTagsInContent.encryptNip44(privateTags, signer),
tags = tags,
signer = signer,
createdAt = createdAt,
)
suspend fun resign(
content: String,
tags: TagArray,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoritePodcastsListEvent {
val newTags =
if (tags.fastAny(AltTag::match)) {
tags
} else {
tags + AltTag.assemble(ALT)
}
return signer.sign(createdAt, KIND, newTags, content)
}
}
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.metadata
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-F4 show-level metadata for a podcast. Authored by the podcast's own pubkey
* (each podcast is its own Nostr keypair). Replaceable: each new event of this
* kind replaces the previous version for that pubkey.
*
* Podcast-specific clients SHOULD read from [PodcastMetadataEvent] directly and
* ignore the podcast's `kind:0` profile, per spec — the show name and cover art
* shipped here are the canonical ones for podcast UIs even if the same pubkey
* also publishes a microblogging profile.
*/
@Immutable
class PodcastMetadataEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun websites() = tags.mapNotNull(WebsiteTag::parse)
/**
* Returns claimed authors and their roles. The spec warns these claims are
* unverified — a podcast can name anyone as author. Before surfacing an author
* to users, cross-check with an [com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent]
* counter-claim signed by that author's own key.
*/
fun claimedAuthors() = tags.mapNotNull(AuthorTag::parse)
companion object {
const val KIND = 10154
const val ALT_DESCRIPTION = "Podcast metadata"
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun build(
title: String,
image: String,
description: String,
websites: List<String> = emptyList(),
authors: List<AuthorTag> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PodcastMetadataEvent>.() -> Unit = {},
) = eventTemplate<PodcastMetadataEvent>(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
title(title)
image(image)
description(description)
websites.forEach { website(it) }
authors.forEach { author(it) }
initializer()
}
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.metadata
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag
fun TagArrayBuilder<PodcastMetadataEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<PodcastMetadataEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<PodcastMetadataEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<PodcastMetadataEvent>.website(url: String) = add(WebsiteTag.assemble(url))
fun TagArrayBuilder<PodcastMetadataEvent>.author(author: AuthorTag) = add(AuthorTag.assemble(author))
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* NIP-F4 author p-tag: `["p", "<podcast-author-pubkey>", "<role>"]`.
*
* Role is optional and, per spec, may be `"host"`, `"cohost"`, or `"editor"`.
* Unknown role strings are preserved verbatim so future role values round-trip;
* clients should validate against [Role] before displaying.
*
* NOTE: this tag overloads `p`'s third slot for role, NOT the usual relay hint.
* Don't reuse this parser on p-tags from other event kinds.
*/
@Stable
class AuthorTag(
val pubKey: HexKey,
val role: String? = null,
) {
fun toTagArray() = assemble(pubKey, role)
companion object {
const val TAG_NAME = "p"
const val ROLE_HOST = "host"
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 }
// 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)
}
fun assemble(
pubKey: HexKey,
role: String? = null,
): Array<String> =
if (role.isNullOrEmpty()) {
arrayOf(TAG_NAME, pubKey)
} else {
arrayOf(TAG_NAME, pubKey, role)
}
fun assemble(author: AuthorTag) = assemble(author.pubKey, author.role)
fun assemble(authors: List<AuthorTag>) = authors.map { assemble(it) }
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class DescriptionTag {
companion object {
const val TAG_NAME = "description"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(description: String) = arrayOf(TAG_NAME, description)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ImageTag {
companion object {
const val TAG_NAME = "image"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class TitleTag {
companion object {
const val TAG_NAME = "title"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(title: String) = arrayOf(TAG_NAME, title)
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class WebsiteTag {
companion object {
const val TAG_NAME = "website"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
fun assemble(urls: List<String>) = urls.map { assemble(it) }
}
}
@@ -292,6 +292,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
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
interface EventBuilder {
fun build(
@@ -536,6 +540,10 @@ class EventFactory {
ZapPollEvent.KIND -> ZapPollEvent(id, pubKey, createdAt, tags, content, sig)
PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig)
PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig)
PodcastMetadataEvent.KIND -> PodcastMetadataEvent(id, pubKey, createdAt, tags, content, sig)
PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig)
AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig)
FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig)
ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AuthoredPodcastsEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
private val unrelated = "0000000000000000000000000000000000000000000000000000000000000001"
@Test
fun `kind is 10064`() {
assertEquals(10064, AuthoredPodcastsEvent.KIND)
}
@Test
fun `build round-trips podcast pubkeys`() {
val template =
AuthoredPodcastsEvent.build(
podcasts = listOf(UserTag(podcast1), UserTag(podcast2)),
)
val event = signer.sign<AuthoredPodcastsEvent>(template)
assertEquals(listOf(podcast1, podcast2), event.authoredKeys())
assertTrue(event.authors(podcast1))
assertTrue(event.authors(podcast2))
assertFalse(event.authors(unrelated))
}
@Test
fun `is replaceable with empty d-tag`() {
val template = AuthoredPodcastsEvent.build(emptyList())
val event = signer.sign<AuthoredPodcastsEvent>(template)
assertEquals("", event.dTag())
}
@Test
fun `linkedPubKeys returns all authored podcast keys`() {
val template =
AuthoredPodcastsEvent.build(
podcasts = listOf(UserTag(podcast1), UserTag(podcast2)),
)
val event = signer.sign<AuthoredPodcastsEvent>(template)
assertEquals(setOf(podcast1, podcast2), event.linkedPubKeys().toSet())
}
}
@@ -0,0 +1,211 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class FavoritePodcastsListEventTest {
private val signer =
NostrSignerInternal(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val podcast1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val podcast2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
@Test
fun `kind is 10054`() {
assertEquals(10054, FavoritePodcastsListEvent.KIND)
}
@Test
fun `create with public favorites round-trips`() =
runTest {
val event =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)),
signer = signer,
createdAt = 1_700_000_000,
)
val pubs = event.publicFavorites()
assertEquals(2, pubs.size)
assertEquals(podcast1, pubs[0].pubKey)
assertEquals(podcast2, pubs[1].pubKey)
}
@Test
fun `create with private favorites hides them from public tags`() =
runTest {
val event =
FavoritePodcastsListEvent.create(
privateFavorites = listOf(UserTag(podcast1)),
signer = signer,
createdAt = 1_700_000_000,
)
assertFalse(
event.tags.any { it.size >= 2 && it[0] == "p" && it[1] == podcast1 },
"Private favorite must not be exposed as a public p-tag",
)
assertTrue(event.content.isNotEmpty(), "Content must hold encrypted private tags")
val priv = assertNotNull(event.privateFavorites(signer))
assertEquals(1, priv.size)
assertEquals(podcast1, priv[0].pubKey)
}
@Test
fun `add public favorite appends to list and dedupes by pubkey`() =
runTest {
val first =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1)),
signer = signer,
createdAt = 1_700_000_000,
)
val second =
FavoritePodcastsListEvent.add(
earlierVersion = first,
podcast = UserTag(podcast2),
isPrivate = false,
signer = signer,
createdAt = 1_700_000_001,
)
assertEquals(
listOf(podcast1, podcast2),
second.publicFavorites().map { it.pubKey },
)
// Adding podcast1 again must not double it.
val third =
FavoritePodcastsListEvent.add(
earlierVersion = second,
podcast = UserTag(podcast1),
isPrivate = false,
signer = signer,
createdAt = 1_700_000_002,
)
assertEquals(
listOf(podcast2, podcast1),
third.publicFavorites().map { it.pubKey },
)
}
@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 {
val first =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1), UserTag(podcast2)),
signer = signer,
createdAt = 1_700_000_000,
)
val second =
FavoritePodcastsListEvent.remove(
earlierVersion = first,
podcast = UserTag(podcast1),
signer = signer,
createdAt = 1_700_000_001,
)
assertEquals(listOf(podcast2), second.publicFavorites().map { it.pubKey })
}
@Test
fun `alt tag is always present`() =
runTest {
val event =
FavoritePodcastsListEvent.create(
publicFavorites = listOf(UserTag(podcast1)),
signer = signer,
createdAt = 1_700_000_000,
)
assertTrue(event.tags.any { it[0] == "alt" && it[1] == FavoritePodcastsListEvent.ALT })
}
}
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PodcastEpisodeEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
@Test
fun `kind is 54`() {
assertEquals(54, PodcastEpisodeEvent.KIND)
}
@Test
fun `build round-trips fields and content`() {
val markdown = "# Episode notes\n\nSome **bold** text."
val template =
PodcastEpisodeEvent.build(
title = "Ep 1",
description = "First episode",
audios = listOf(AudioTag("https://example.com/ep1.mp3", "audio/mpeg")),
markdownContent = markdown,
image = "https://example.com/ep1.png",
)
val event = signer.sign<PodcastEpisodeEvent>(template)
assertEquals("Ep 1", event.title())
assertEquals("First episode", event.description())
assertEquals("https://example.com/ep1.png", event.image())
assertEquals(markdown, event.content)
val audios = event.audios()
assertEquals(1, audios.size)
assertEquals("https://example.com/ep1.mp3", audios[0].url)
assertEquals("audio/mpeg", audios[0].mediaType)
}
@Test
fun `image is optional`() {
val template =
PodcastEpisodeEvent.build(
title = "Ep 2",
description = "No artwork",
audios = listOf(AudioTag("https://example.com/ep2.mp3")),
)
val event = signer.sign<PodcastEpisodeEvent>(template)
assertNull(event.image())
}
@Test
fun `multiple audio tags are preserved with optional media type`() {
val template =
PodcastEpisodeEvent.build(
title = "Ep 3",
description = "Multi-codec",
audios =
listOf(
AudioTag("https://example.com/ep3.mp3", "audio/mpeg"),
AudioTag("https://example.com/ep3.opus", "audio/opus"),
AudioTag("https://example.com/ep3.unknown"),
),
)
val event = signer.sign<PodcastEpisodeEvent>(template)
val audios = event.audios()
assertEquals(3, audios.size)
assertEquals("audio/mpeg", audios[0].mediaType)
assertEquals("audio/opus", audios[1].mediaType)
assertNull(audios[2].mediaType)
}
@Test
fun `alt tag includes title`() {
val template =
PodcastEpisodeEvent.build(
title = "Ep Title",
description = "x",
audios = listOf(AudioTag("https://example.com/x.mp3")),
)
val event = signer.sign<PodcastEpisodeEvent>(template)
val alt = event.tags.first { it[0] == "alt" }
assertTrue(alt[1].endsWith("Ep Title"))
assertTrue(alt[1].startsWith(PodcastEpisodeEvent.ALT_DESCRIPTION_PREFIX))
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipF4Podcasts
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PodcastMetadataEventTest {
private val signer =
DeterministicSigner(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val authorPubKey1 = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
private val authorPubKey2 = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
@Test
fun `kind is 10154`() {
assertEquals(10154, PodcastMetadataEvent.KIND)
}
@Test
fun `build round-trips required fields`() {
val template =
PodcastMetadataEvent.build(
title = "Show Title",
image = "https://example.com/cover.png",
description = "A show about things.",
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertEquals("Show Title", event.title())
assertEquals("https://example.com/cover.png", event.image())
assertEquals("A show about things.", event.description())
assertTrue(event.websites().isEmpty())
assertTrue(event.claimedAuthors().isEmpty())
}
@Test
fun `build round-trips multiple websites and authors with roles`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
websites = listOf("https://show.example", "https://show.example/feed"),
authors =
listOf(
AuthorTag(authorPubKey1, AuthorTag.ROLE_HOST),
AuthorTag(authorPubKey2, AuthorTag.ROLE_COHOST),
),
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertEquals(
listOf("https://show.example", "https://show.example/feed"),
event.websites(),
)
val authors = event.claimedAuthors()
assertEquals(2, authors.size)
assertEquals(authorPubKey1, authors[0].pubKey)
assertEquals(AuthorTag.ROLE_HOST, authors[0].role)
assertEquals(authorPubKey2, authors[1].pubKey)
assertEquals(AuthorTag.ROLE_COHOST, authors[1].role)
}
@Test
fun `author tag without role parses with null role`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
authors = listOf(AuthorTag(authorPubKey1, null)),
)
val event = signer.sign<PodcastMetadataEvent>(template)
val authors = event.claimedAuthors()
assertEquals(1, authors.size)
assertEquals(authorPubKey1, authors[0].pubKey)
assertNull(authors[0].role)
}
@Test
fun `alt tag is emitted`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertTrue(event.tags.any { it[0] == "alt" && it[1] == PodcastMetadataEvent.ALT_DESCRIPTION })
}
@Test
fun `is replaceable with empty d-tag`() {
val template =
PodcastMetadataEvent.build(
title = "Show",
image = "https://example.com/cover.png",
description = "Desc",
)
val event = signer.sign<PodcastMetadataEvent>(template)
assertEquals("", event.dTag())
assertEquals(10154, event.address().kind)
assertEquals(event.pubKey, event.address().pubKeyHex)
}
}